From 8610856a3025aeef771ffa0d80242857a9499d7c Mon Sep 17 00:00:00 2001 From: ansabgillani Date: Mon, 10 Jan 2022 11:13:21 +0500 Subject: [PATCH] feat: Program Inspectors API View --- lms/djangoapps/support/tests/test_views.py | 279 ++++++++++++++++++ lms/djangoapps/support/urls.py | 6 + .../support/views/program_enrollments.py | 212 +++++++++---- 3 files changed, 441 insertions(+), 56 deletions(-) diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 310d040796..bb337af309 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -1113,6 +1113,285 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): assert expected_info == render_call_dict['learner_program_enrollments'] +@ddt.ddt +class ProgramEnrollmentsInspectorAPIViewTests(SupportViewTestCase): + """ + View tests for Program Enrollments Inspector API + """ + _url = reverse("support:program_enrollments_inspector_details") + + def setUp(self): + super().setUp() + SupportStaffRole().add_users(self.user) + self.program_uuid = str(uuid4()) + self.external_user_key = 'abcaaa' + # Setup three orgs and their SAML providers + self.org_key_list = ['test_org', 'donut_org', 'tri_org'] + for org_key in self.org_key_list: + lms_org = OrganizationFactory( + short_name=org_key + ) + SAMLProviderConfigFactory( + organization=lms_org, + slug=org_key, + enabled=True, + ) + self.no_saml_org_key = 'no_saml_org' + self.no_saml_lms_org = OrganizationFactory( + short_name=self.no_saml_org_key + ) + + def _serialize_datetime(self, dt): + return dt.strftime('%Y-%m-%dT%H:%M:%S') + + def test_default_response(self): + response = self.client.get(self._url) + content = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert '' == content['org_keys'] + + def _construct_user(self, username, org_key=None, external_user_key=None): + """ + Provided the username, create an edx account user. If the org_key is provided, + SSO link the user with the IdP associated with org_key. Return the created user and + expected user info object from the view + """ + user = UserFactory(username=username) + user_info = { + 'username': user.username, + 'email': user.email + } + if org_key and external_user_key: + user_social_auth = UserSocialAuth.objects.create( + user=user, + uid=f'{org_key}:{external_user_key}', + provider='tpa-saml' + ) + user_info['sso_list'] = [{ + 'uid': user_social_auth.uid + }] + return user, user_info + + def _construct_enrollments(self, program_uuids, course_ids, external_user_key, edx_user=None): + """ + A helper function to setup the program enrollments for a given learner. + If the edx user is provided, it will try to SSO the user with the enrollments + Return the expected info object that should be created based on the model setup + """ + program_enrollments = [] + for program_uuid in program_uuids: + course_enrollment = None + program_enrollment = ProgramEnrollmentFactory.create( + external_user_key=external_user_key, + program_uuid=program_uuid, + user=edx_user + ) + + for course_id in course_ids: + if edx_user: + course_enrollment = CourseEnrollmentFactory.create( + course_id=course_id, + user=edx_user, + mode=CourseMode.MASTERS, + is_active=True + ) + + program_course_enrollment = ProgramCourseEnrollmentFactory.create( # lint-amnesty, pylint: disable=unused-variable + program_enrollment=program_enrollment, + course_key=course_id, + course_enrollment=course_enrollment, + status='active', + ) + + program_enrollments.append(program_enrollment) + + serialized = ProgramEnrollmentSerializer(program_enrollments, many=True) + return serialized.data + + def _construct_id_verification(self, user): + """ + Helper function to create the SSO verified record for the user + so that the user is ID Verified + """ + SSOVerificationFactory( + identity_provider_slug=self.org_key_list[0], + user=user, + ) + return IDVerificationService.user_status(user) + + def test_search_username_well_connected_user(self): + created_user, expected_user_info = self._construct_user( + 'test_user_connected', + self.org_key_list[0], + self.external_user_key + ) + id_verified = self._construct_id_verification(created_user) + expected_enrollments = self._construct_enrollments( + [self.program_uuid], + [self.course.id], + self.external_user_key, + created_user + ) + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'enrollments': expected_enrollments, + 'id_verification': id_verified + } + assert expected_info == response['learner_program_enrollments'] + + def test_search_username_user_not_connected(self): + created_user, expected_user_info = self._construct_user('user_not_connected') + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'id_verification': IDVerificationService.user_status(created_user) + } + + assert expected_info == response['learner_program_enrollments'] + + def test_search_username_user_no_enrollment(self): + created_user, expected_user_info = self._construct_user( + 'user_connected', + self.org_key_list[0], + self.external_user_key + ) + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'id_verification': IDVerificationService.user_status(created_user), + } + assert expected_info == response['learner_program_enrollments'] + + def test_search_username_user_no_course_enrollment(self): + created_user, expected_user_info = self._construct_user( + 'user_connected', + self.org_key_list[0], + self.external_user_key + ) + expected_enrollments = self._construct_enrollments( + [self.program_uuid], + [], + self.external_user_key, + created_user, + ) + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'enrollments': expected_enrollments, + 'id_verification': IDVerificationService.user_status(created_user), + } + + assert expected_info == response['learner_program_enrollments'] + + def test_search_username_user_not_connected_with_enrollments(self): + created_user, expected_user_info = self._construct_user( + 'user_not_connected' + ) + self._construct_enrollments( + [self.program_uuid], + [], + self.external_user_key, + ) + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'id_verification': IDVerificationService.user_status(created_user), + } + assert expected_info == response['learner_program_enrollments'] + + def test_search_username_user_id_verified(self): + created_user, expected_user_info = self._construct_user( + 'user_not_connected' + ) + id_verified = self._construct_id_verification(created_user) + expected_info = { + 'user': expected_user_info, + 'id_verification': id_verified + } + response = self.client.get(self._url + f'?edx_user={created_user.username}&org_key={self.org_key_list[0]}') + response = json.loads(response.content.decode('utf-8')) + assert expected_info == response['learner_program_enrollments'] + + @ddt.data( + ('', 'test_org'), + ('bad_key', '') + ) + @ddt.unpack + def test_search_no_external_user_key(self, user_key, org_key): + response = self.client.get(self._url + f'?external_user_key={user_key}&org_key={org_key}') + response = json.loads(response.content.decode('utf-8')) + expected_error = ( + "To perform a search, you must provide either the student's " + "(a) edX username, " + "(b) email address associated with their edX account, or " + "(c) Identity-providing institution and external key!" + ) + + assert {} == response['learner_program_enrollments'] + assert expected_error == response['error'] + + def test_search_external_user_not_connected(self): + expected_enrollments = self._construct_enrollments( + [self.program_uuid], + [self.course.id], + self.external_user_key, + ) + response = self.client.get( + self._url + f'?external_user_key={self.external_user_key}&org_key={self.org_key_list[0]}' + ) + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': { + 'external_user_key': self.external_user_key, + }, + 'enrollments': expected_enrollments + } + assert expected_info == response['learner_program_enrollments'] + + def test_search_external_user_not_in_system(self): + external_user_key = 'not_in_system' + response = self.client.get( + self._url + f'?external_user_key={external_user_key}&org_key={self.org_key_list[0]}' + ) + response = json.loads(response.content.decode('utf-8')) + expected_error = 'No user found for external key {} for institution {}'.format( + external_user_key, self.org_key_list[0] + ) + assert expected_error == response['error'] + + def test_search_external_user_case_insensitive(self): + external_user_key = 'AbCdEf123' + requested_external_user_key = 'aBcDeF123' + created_user, expected_user_info = self._construct_user( + 'test_user_connected', + self.org_key_list[0], + external_user_key + ) + expected_enrollments = self._construct_enrollments( + [self.program_uuid], + [self.course.id], + external_user_key, + created_user + ) + id_verified = self._construct_id_verification(created_user) + response = self.client.get( + self._url + f'?external_user_key={requested_external_user_key}&org_key={self.org_key_list[0]}' + ) + response = json.loads(response.content.decode('utf-8')) + expected_info = { + 'user': expected_user_info, + 'enrollments': expected_enrollments, + 'id_verification': id_verified, + } + assert expected_info == response['learner_program_enrollments'] + + class SsoRecordsTests(SupportViewTestCase): # lint-amnesty, pylint: disable=missing-class-docstring def setUp(self): diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 507a79aa02..94feadfdc0 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -17,6 +17,7 @@ from .views.program_enrollments import ( LinkProgramEnrollmentSupportAPIView, ProgramEnrollmentsInspectorView, SAMLProvidersWithOrg, + ProgramEnrollmentsInspectorAPIView, ) from .views.sso_records import SsoView from .views.onboarding_status import OnboardingView @@ -71,6 +72,11 @@ urlpatterns = [ SAMLProvidersWithOrg.as_view(), name='get_saml_providers' ), + re_path( + r'program_enrollments_inspector_details/?$', + ProgramEnrollmentsInspectorAPIView.as_view(), + name='program_enrollments_inspector_details' + ), re_path(r'sso_records/(?P[\w.@+-]+)?$', SsoView.as_view(), name='sso_records'), re_path( r'onboarding_status/(?P[\w.@+-]+)?$', diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py index 789daae381..c912423197 100644 --- a/lms/djangoapps/support/views/program_enrollments.py +++ b/lms/djangoapps/support/views/program_enrollments.py @@ -116,64 +116,11 @@ class LinkProgramEnrollmentSupportAPIView(APIView): return Response(data) -class ProgramEnrollmentsInspectorView(View): +class ProgramEnrollmentInspector: """ - The view to search and display the program enrollments - information of a learner. + A common class to provide functionality of search and display the program enrollments + information of a learner for Program Inspector Views and APIViews. """ - exclude_from_schema = True - CONSOLE_TEMPLATE_PATH = 'support/program_enrollments_inspector.html' - - @method_decorator(require_support_permission) - def get(self, request): - """ - Based on the query string parameters passed through the GET request - Search the data store for information about ProgramEnrollment and - SSO linkage with the user. - """ - search_error = '' - edx_username_or_email = request.GET.get('edx_user', '').strip() - org_key = request.GET.get('org_key', '').strip() - external_user_key = request.GET.get('external_user_key', '').strip() - learner_program_enrollments = {} - saml_providers_with_org_key = self._get_org_keys_and_idps() - selected_provider = None - if org_key: - selected_provider = saml_providers_with_org_key.get(org_key) - if edx_username_or_email: - learner_program_enrollments, search_error = self._get_account_info( - edx_username_or_email, - selected_provider, - ) - elif org_key and external_user_key: - learner_program_enrollments = self._get_external_user_info( - external_user_key, - org_key, - selected_provider, - ) - if not learner_program_enrollments: - search_error = 'No user found for external key {} for institution {}'.format( - external_user_key, org_key - ) - elif not org_key and not external_user_key: - # This is initial rendering state. - pass - else: - search_error = ( - "To perform a search, you must provide either the student's " - "(a) edX username, " - "(b) email address associated with their edX account, or " - "(c) Identity-providing institution and external key!" - ) - - return render_to_response( - self.CONSOLE_TEMPLATE_PATH, - { - 'error': search_error, - 'learner_program_enrollments': learner_program_enrollments, - 'org_keys': sorted(saml_providers_with_org_key.keys()), - } - ) def _get_org_keys_and_idps(self): """ @@ -297,3 +244,156 @@ class SAMLProvidersWithOrg(APIView): ).select_related('organization') return [saml_provider.organization.short_name for saml_provider in saml_providers] + + +class ProgramEnrollmentsInspectorView(ProgramEnrollmentInspector, View): + """ + The view to search and display the program enrollments + information of a learner. + """ + exclude_from_schema = True + CONSOLE_TEMPLATE_PATH = 'support/program_enrollments_inspector.html' + + @method_decorator(require_support_permission) + def get(self, request): + """ + Based on the query string parameters passed through the GET request + Search the data store for information about ProgramEnrollment and + SSO linkage with the user. + """ + search_error = '' + edx_username_or_email = request.GET.get('edx_user', '').strip() + org_key = request.GET.get('org_key', '').strip() + external_user_key = request.GET.get('external_user_key', '').strip() + learner_program_enrollments = {} + saml_providers_with_org_key = self._get_org_keys_and_idps() + selected_provider = None + if org_key: + selected_provider = saml_providers_with_org_key.get(org_key) + if edx_username_or_email: + learner_program_enrollments, search_error = self._get_account_info( + edx_username_or_email, + selected_provider, + ) + elif org_key and external_user_key: + learner_program_enrollments = self._get_external_user_info( + external_user_key, + org_key, + selected_provider, + ) + if not learner_program_enrollments: + search_error = 'No user found for external key {} for institution {}'.format( + external_user_key, org_key + ) + elif not org_key and not external_user_key: + # This is initial rendering state. + pass + else: + search_error = ( + "To perform a search, you must provide either the student's " + "(a) edX username, " + "(b) email address associated with their edX account, or " + "(c) Identity-providing institution and external key!" + ) + + return render_to_response( + self.CONSOLE_TEMPLATE_PATH, + { + 'error': search_error, + 'learner_program_enrollments': learner_program_enrollments, + 'org_keys': sorted(saml_providers_with_org_key.keys()), + } + ) + + +class ProgramEnrollmentsInspectorAPIView(ProgramEnrollmentInspector, APIView): + """ + The APIview to search and display the program enrollments + information of a learner. + """ + + authentication_classes = ( + JwtAuthentication, SessionAuthentication + ) + permission_classes = ( + IsAuthenticated, + ) + + @method_decorator(require_support_permission) + def get(self, request): + """ + Based on the query string parameters passed through the GET request + Search the data store for information about ProgramEnrollment and + SSO linkage with the user. + * Example Request: + - GET / support/program_enrollments_inspector_details? + edx_user=&org_key=&external_user_key= + * Example Response: + { + learner_program_enrollments: { + "user": { + "username": "edx", + "email": "edx@example.com" + }, + "id_verification": { + "status": "none", + "error": , + "should_display": true, + "status_date": , + "verification_expiry": + }, + "enrollments": [ + { + "created": "2021-11-25T04:56:25", + "modified": "2021-12-19T22:27:34", + "external_user_key": "testuser", + "status": "enrolled", + "program_uuid": , + "program_course_enrollments": [], + "program_name": + } + ], + "user": { + "external_user_key": "testuser" + } + }, + org_key: < org_key > + errors: 'Error messages for invalid query' + } + """ + search_error = '' + edx_username_or_email = request.query_params.get('edx_user', '').strip() + org_key = request.query_params.get('org_key', '').strip() + external_user_key = request.query_params.get('external_user_key', '').strip() + learner_program_enrollments = {} + saml_providers_with_org_key = self._get_org_keys_and_idps() + selected_provider = None + if org_key: + selected_provider = saml_providers_with_org_key.get(org_key) + if edx_username_or_email: + learner_program_enrollments, search_error = self._get_account_info( + edx_username_or_email, + selected_provider, + ) + elif org_key and external_user_key: + learner_program_enrollments = self._get_external_user_info( + external_user_key, + org_key, + selected_provider, + ) + if not learner_program_enrollments: + search_error = 'No user found for external key {} for institution {}'.format( + external_user_key, org_key + ) + else: + search_error = ( + "To perform a search, you must provide either the student's " + "(a) edX username, " + "(b) email address associated with their edX account, or " + "(c) Identity-providing institution and external key!" + ) + return Response(data={ + 'error': search_error, + 'learner_program_enrollments': learner_program_enrollments, + 'org_keys': org_key, + })