From 4c5d56ef0657737ce6ad7360eff888a0bd30b51a Mon Sep 17 00:00:00 2001 From: Simon Chen <schen@edx.org> Date: Mon, 22 Mar 2021 14:25:34 -0400 Subject: [PATCH] MST-682 Add external_user_key to the student profile CSV (#27091) * MST-682 Add external_user_key to the student profile CSV This is a request from some Masters school partners. They would like to download the student enrolled list with the Masters external_user_key data referenced. This way, the schools can properly match the students enrolled in the course with the students enrolled through Masters enrollment system --- lms/djangoapps/instructor/tests/test_api.py | 27 +++++++++++ lms/djangoapps/instructor/views/api.py | 3 +- lms/djangoapps/instructor_analytics/basic.py | 15 +++++- .../instructor_analytics/tests/test_basic.py | 28 ++++++++++- .../program_enrollments/api/__init__.py | 1 + .../program_enrollments/api/reading.py | 41 +++++++++++++++++ .../api/tests/test_reading.py | 46 +++++++++++++++++++ 7 files changed, 157 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 9acf4e43406..9f4499a42e5 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -81,6 +81,7 @@ from lms.djangoapps.instructor_task.api_helper import ( QueueConnectionError, generate_already_running_error_message ) +from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.course_date_signals.handlers import extract_dates from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA @@ -2616,6 +2617,32 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment assert ('team' in res_json['feature_names']) == has_teams + @ddt.data(True, False) + def test_get_students_features_external_user_key(self, has_program_enrollments): + external_key_dict = {} + if has_program_enrollments: + for i in range(len(self.students)): + student = self.students[i] + external_key = "{}_{}".format(student.username, i) + ProgramEnrollmentFactory.create(user=student, external_user_key=external_key) + external_key_dict[student.username] = external_key + + url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) + + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode('utf-8')) + assert 'external_user_key' in res_json['feature_names'] + for student in self.students: + student_json = [ + x for x in res_json['students'] + if x['username'] == student.username + ][0] + assert student_json['username'] == student.username + if has_program_enrollments: + assert student_json['external_user_key'] == external_key_dict[student.username] + else: + assert student_json['external_user_key'] == '' + def test_get_students_who_may_enroll(self): """ Test whether get_students_who_may_enroll returns an appropriate diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d5bc2412676..959f8cce3cf 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1141,7 +1141,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'id', 'username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'enrollment_mode', 'verification_status', - 'last_login', 'date_joined', + 'last_login', 'date_joined', 'external_user_key' ] # Provide human-friendly and translatable names for these features. These names @@ -1163,6 +1163,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'verification_status': _('Verification Status'), 'last_login': _('Last Login'), 'date_joined': _('Date Joined'), + 'external_user_key': _('External User Key'), } if is_course_cohorted(course.id): diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 492a365faa1..26cd63542a4 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -23,6 +23,7 @@ from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentA from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.grades.api import context as grades_context +from lms.djangoapps.program_enrollments.api import fetch_program_enrollments_by_students from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.markup import HTML, Text @@ -35,6 +36,7 @@ STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'em PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'meta', 'city', 'country') +PROGRAM_ENROLLMENT_FEATURES = ('external_user_key', ) ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status') ORDER_FEATURES = ('purchase_time',) @@ -46,7 +48,7 @@ SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_co 'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode', 'bill_to_country', 'order_type', 'created') -AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES +AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid') COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active') CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason') @@ -96,6 +98,8 @@ def enrolled_students_features(course_key, features): include_team_column = 'team' in features include_enrollment_mode = 'enrollment_mode' in features include_verification_status = 'verification_status' in features + include_program_enrollments = 'external_user_key' in features + external_user_key_dict = {} students = User.objects.filter( courseenrollment__course_id=course_key, @@ -108,6 +112,11 @@ def enrolled_students_features(course_key, features): if include_team_column: students = students.prefetch_related('teams') + if include_program_enrollments and len(students) > 0: + program_enrollments = fetch_program_enrollments_by_students(users=students, realized_only=True) + for program_enrollment in program_enrollments: + external_user_key_dict[program_enrollment.user_id] = program_enrollment.external_user_key + def extract_attr(student, feature): """Evaluate a student attribute that is ready for JSON serialization""" attr = getattr(student, feature) @@ -167,6 +176,10 @@ def enrolled_students_features(course_key, features): if include_enrollment_mode: student_dict['enrollment_mode'] = enrollment_mode + if include_program_enrollments: + # extra external_user_key + student_dict['external_user_key'] = external_user_key_dict.get(student.id, '') + return student_dict return [extract_student(student, features) for student in students] diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index a7478de3e5b..a4469a93d1c 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -16,6 +16,7 @@ from lms.djangoapps.courseware.tests.factories import InstructorFactory from lms.djangoapps.instructor_analytics.basic import ( # lint-amnesty, pylint: disable=unused-import AVAILABLE_FEATURES, PROFILE_FEATURES, + PROGRAM_ENROLLMENT_FEATURES, STUDENT_FEATURES, StudentModule, enrolled_students_features, @@ -24,6 +25,7 @@ from lms.djangoapps.instructor_analytics.basic import ( # lint-amnesty, pylint: list_may_enroll, list_problem_responses ) +from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed from common.djangoapps.student.tests.factories import UserFactory @@ -224,9 +226,31 @@ class TestAnalyticsBasic(ModuleStoreTestCase): else: assert report['cohort'] == '[unassigned]' + def test_enrolled_student_features_external_user_keys(self): + query_features = ('username', 'name', 'email', 'city', 'country', 'external_user_key') + username_with_external_user_key_dict = {} + for i in range(len(self.users)): + # Setup some users with ProgramEnrollments + if i % 2 == 0: + user = self.users[i] + external_user_key = '{}_{}'.format(user.username, i) + ProgramEnrollmentFactory.create(user=user, external_user_key=external_user_key) + username_with_external_user_key_dict[user.username] = external_user_key + + with self.assertNumQueries(2): + userreports = enrolled_students_features(self.course_key, query_features) + assert len(userreports) == 30 + for report in userreports: + username = report['username'] + external_key = username_with_external_user_key_dict.get(username) + if external_key: + assert external_key == report['external_user_key'] + else: + assert '' == report['external_user_key'] + def test_available_features(self): - assert len(AVAILABLE_FEATURES) == len((STUDENT_FEATURES + PROFILE_FEATURES)) - assert set(AVAILABLE_FEATURES) == set((STUDENT_FEATURES + PROFILE_FEATURES)) + assert len(AVAILABLE_FEATURES) == len((STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES)) + assert set(AVAILABLE_FEATURES) == set((STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES)) def test_list_may_enroll(self): may_enroll = list_may_enroll(self.course_key, ['email']) diff --git a/lms/djangoapps/program_enrollments/api/__init__.py b/lms/djangoapps/program_enrollments/api/__init__.py index 1b35870eedd..0d3762db0fd 100644 --- a/lms/djangoapps/program_enrollments/api/__init__.py +++ b/lms/djangoapps/program_enrollments/api/__init__.py @@ -21,6 +21,7 @@ from .reading import ( fetch_program_course_enrollments_by_students, fetch_program_enrollments, fetch_program_enrollments_by_student, + fetch_program_enrollments_by_students, get_external_key_by_user_and_course, get_org_key_for_program, get_program_course_enrollment, diff --git a/lms/djangoapps/program_enrollments/api/reading.py b/lms/djangoapps/program_enrollments/api/reading.py index 4b2dae923c2..1ec0f500034 100644 --- a/lms/djangoapps/program_enrollments/api/reading.py +++ b/lms/djangoapps/program_enrollments/api/reading.py @@ -271,6 +271,47 @@ def fetch_program_enrollments_by_student( return ProgramEnrollment.objects.filter(**_remove_none_values(filters)) +def fetch_program_enrollments_by_students( + users=None, + external_user_keys=None, + program_enrollment_statuses=None, + realized_only=False, + waiting_only=False, +): + """ + Fetch program enrollments for a specific list of students. + + Required arguments (at least one must be provided): + * users (iterable[User]) + * external_user_keys (iterable[str]) + + Optional arguments: + * program_enrollment_statuses (iterable[str]) + * realized_only (bool) + * waiting_only (bool) + + Optional arguments are used as filtersets if they are not None. + + Returns: queryset[ProgramEnrollment] + """ + if not (users or external_user_keys): + raise ValueError(_STUDENT_LIST_ARG_ERROR_MESSAGE) + if realized_only and waiting_only: + raise ValueError( + _REALIZED_FILTER_ERROR_TEMPLATE.format("realized_only", "waiting_only") + ) + filters = { + "user__in": users, + "external_user_key__in": external_user_keys, + "status__in": program_enrollment_statuses, + } + if realized_only: + filters["user__isnull"] = False + if waiting_only: + filters["user__isnull"] = True + return ProgramEnrollment.objects.filter(**_remove_none_values(filters)) + + def fetch_program_course_enrollments_by_students( users=None, external_user_keys=None, diff --git a/lms/djangoapps/program_enrollments/api/tests/test_reading.py b/lms/djangoapps/program_enrollments/api/tests/test_reading.py index 0e41ea64844..b5d60c54616 100644 --- a/lms/djangoapps/program_enrollments/api/tests/test_reading.py +++ b/lms/djangoapps/program_enrollments/api/tests/test_reading.py @@ -42,6 +42,7 @@ from ..reading import ( fetch_program_course_enrollments_by_students, fetch_program_enrollments, fetch_program_enrollments_by_student, + fetch_program_enrollments_by_students, get_external_key_by_user_and_course, get_program_course_enrollment, get_program_enrollment, @@ -371,6 +372,51 @@ class ProgramEnrollmentReadingTests(TestCase): actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments} assert actual_enrollment_ids == expected_enrollment_ids + @ddt.data( + + # User with no enrollments + ( + {'usernames': [username_0]}, + set(), + ), + + # Filters + ( + { + 'usernames': [username_3], + }, + {3, 7}, + ), + + # More filters + ( + { + 'usernames': [username_3], + 'external_user_keys': [ext_3], + 'program_enrollment_statuses': {PEStatuses.SUSPENDED, PEStatuses.CANCELED}, + }, + {7}, + ), + + # Realized-only filter + ( + {'usernames': [username_4], 'realized_only': True}, + {4}, + ), + + # Waiting-only filter + ( + {'external_user_keys': [ext_4], 'waiting_only': True}, + {8}, + ), + ) + @ddt.unpack + def test_fetch_program_enrollments_by_students(self, kwargs, expected_enrollment_ids): + kwargs = self._usernames_to_users(kwargs) + actual_enrollments = fetch_program_enrollments_by_students(**kwargs) + actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments} + assert actual_enrollment_ids == expected_enrollment_ids + @ddt.data( # User with no program enrollments -- GitLab