diff --git a/lms/djangoapps/support/serializers.py b/lms/djangoapps/support/serializers.py index d4099ef181..c7b7c80eab 100644 --- a/lms/djangoapps/support/serializers.py +++ b/lms/djangoapps/support/serializers.py @@ -59,7 +59,7 @@ class ProgramEnrollmentSerializer(serializers.Serializer): model = ProgramEnrollment -def serialize_user_info(user, user_social_auth=None): +def serialize_user_info(user, user_social_auths=None): """ Helper method to serialize resulting in user_info_object based on passed in django models @@ -68,11 +68,9 @@ def serialize_user_info(user, user_social_auth=None): 'username': user.username, 'email': user.email, } - if user_social_auth: - _, external_key = user_social_auth.uid.split(':', 1) - user_info['external_user_key'] = external_key - user_info['sso'] = { - 'uid': user_social_auth.uid, - 'provider': user_social_auth.provider - } + if user_social_auths: + for user_social_auth in user_social_auths: + user_info.setdefault('sso_list', []).append({ + 'uid': user_social_auth.uid, + }) return user_info diff --git a/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx b/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx index dde601b502..190b9b681d 100644 --- a/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx +++ b/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx @@ -2,59 +2,227 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, InputText, StatusAlert, InputSelect } from '@edx/paragon'; -export const ProgramEnrollmentsInspectorPage = props => ( -
- {props.errors.map(errorItem => ( - - ))} -
- +/* +To improve the UI here, we should move this tool to the support Micro-Frontend. +This work will be groomed and covered by MST-180 +*/ +const renderUserSection = userObj => ( +
+

edX account Info

+
+
Username: {userObj.username}
+
Email: {userObj.email}
+ {userObj.external_user_key && ( +
+ External User Key + : {userObj.external_user_key} +
+ )} + {userObj.sso_list ? ( +
+

List of Single Sign On Records:

+
    + {userObj.sso_list.map(sso => ( +
  • {sso.uid}
  • + ))} +
+
+ ) : ( +
There is no Single Sign On record associated with this user!
+ )}
-
- +
+
+); - + +const renderVerificationSection = verificationStatus => ( +
+

ID Verification

+
+
Status: {verificationStatus.status}
+ {verificationStatus.error && ( +
+ Verification Error: {verificationStatus.error} +
+ )} + {verificationStatus.verification_expiry && ( +
+ Verification Expiration Date + : {verificationStatus.verification_expiry} +
+ )}
-
+); + +const renderEnrollmentsSection = enrollments => ( +
+

Program Enrollments

+ {enrollments.map(enrollment => ( +
+

Program {enrollment.program_uuid} Enrollment

+
Status: {enrollment.status}
+
Created: {enrollment.created}
+
Last updated: {enrollment.modified}
+
+ External User Key + : {enrollment.external_user_key} +
+ {enrollment.program_course_enrollments && enrollment.program_course_enrollments.map( + programCourseEnrollment => ( +
+

Course {programCourseEnrollment.course_key}

+
+ Status + : {programCourseEnrollment.status} +
+
+ Created + : {programCourseEnrollment.created} +
+
+ Last updated + : {programCourseEnrollment.modified} +
+ {programCourseEnrollment.course_enrollment && ( +
+

Linked course enrollment

+
Course ID + : {programCourseEnrollment.course_enrollment.course_id} +
+
Is Active + : {String(programCourseEnrollment.course_enrollment.is_active)} +
+
Mode / Track + : {programCourseEnrollment.course_enrollment.mode} +
+
+ )} +
+ ))} +
+ ))} +
+
+); + +const validateInputs = () => { + const inputEdxUser = self.document.getElementById('edx_user'); + const inputExternalKey = self.document.getElementById('external_key'); + const inputAlert = self.document.getElementById('input_alert'); + if (inputEdxUser.value && inputExternalKey.value) { + inputAlert.removeAttribute('hidden'); + self.button.disabled = true; + } else { + inputAlert.setAttribute('hidden', ''); + self.button.disabled = false; + } +}; + +export const ProgramEnrollmentsInspectorPage = props => ( +
+ {JSON.stringify(props.learnerInfo) !== '{}' && (

Search Results

)} + {props.learnerInfo.user && + renderUserSection(props.learnerInfo.user)} + {props.learnerInfo.id_verification && + renderVerificationSection(props.learnerInfo.id_verification)} + {props.learnerInfo.enrollments && + renderEnrollmentsSection(props.learnerInfo.enrollments)} +
+

Search For A Masters Learner Below

+ {props.error && ( + + )} + +
+ +
+
+ + +
+
); ProgramEnrollmentsInspectorPage.propTypes = { - errors: PropTypes.arrayOf(PropTypes.string), + error: PropTypes.string, learnerInfo: PropTypes.shape({ user: PropTypes.shape({ - external_user_key: PropTypes.string, username: PropTypes.string, + email: PropTypes.email, + external_user_key: PropTypes.string, + sso_list: PropTypes.arrayOf( + PropTypes.shape({ + uid: PropTypes.string, + }), + ), + }), + id_verification: PropTypes.shape({ + status: PropTypes.string, + error: PropTypes.string, + verification_expiry: PropTypes.string, }), enrollments: PropTypes.arrayOf( - PropTypes.string, + PropTypes.shape({ + created: PropTypes.string, + modified: PropTypes.string, + program_uuid: PropTypes.string, + status: PropTypes.string, + external_user_key: PropTypes.string, + program_course_enrollments: PropTypes.arrayOf( + PropTypes.shape({ + course_key: PropTypes.string, + created: PropTypes.string, + modified: PropTypes.string, + status: PropTypes.string, + course_enrollment: PropTypes.shape({ + course_id: PropTypes.string, + is_active: PropTypes.bool, + mode: PropTypes.string, + }), + })), + }), ), }), - orgKeys: PropTypes.arrayOf(PropTypes.object), + orgKeys: PropTypes.arrayOf(PropTypes.string), }; ProgramEnrollmentsInspectorPage.defaultProps = { - errors: [], + error: '', learnerInfo: {}, orgKeys: [], }; diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 81dc7aaa81..30110f68c5 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -587,7 +587,9 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): def test_initial_rendering(self): response = self.client.get(self.url) content = six.text_type(response.content, encoding='utf-8') - expected_organization_serialized = '"orgKeys": {}'.format(json.dumps(self.org_key_list)) + expected_organization_serialized = '"orgKeys": {}'.format( + json.dumps(sorted(self.org_key_list)) + ) assert response.status_code == 200 assert expected_organization_serialized in content assert '"learnerInfo": {}' in content @@ -609,11 +611,9 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): uid='{0}:{1}'.format(org_key, external_user_key), provider='tpa-saml' ) - user_info['external_user_key'] = external_user_key - user_info['sso'] = { - 'uid': user_social_auth.uid, - 'provider': user_social_auth.provider - } + 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): @@ -678,7 +678,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): created_user ) self.client.get(self.url, data={ - 'edx_user': created_user.username + 'edx_user': created_user.username, + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -693,7 +694,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): def test_search_username_user_not_connected(self, mocked_render): created_user, expected_user_info = self._construct_user('user_not_connected') self.client.get(self.url, data={ - 'edx_user': created_user.email + 'edx_user': created_user.email, + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -711,7 +713,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): self.external_user_key ) self.client.get(self.url, data={ - 'edx_user': created_user.email + 'edx_user': created_user.email, + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -735,7 +738,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): created_user, ) self.client.get(self.url, data={ - 'edx_user': created_user.email + 'edx_user': created_user.email, + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -757,7 +761,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): self.external_user_key, ) self.client.get(self.url, data={ - 'edx_user': created_user.email + 'edx_user': created_user.email, + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -779,7 +784,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): } self.client.get(self.url, data={ - 'edx_user': created_user.email + 'edx_user': created_user.email, + 'org_key': self.org_key_list[0] }) render_call_dict = mocked_render.call_args[0][1] @@ -801,7 +807,7 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): id_verified = self._construct_id_verification(created_user) self.client.get(self.url, data={ 'external_user_key': self.external_user_key, - 'IdPSelect': self.org_key_list[0] + 'org_key': self.org_key_list[0] }) expected_info = { 'user': expected_user_info, @@ -813,27 +819,27 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): assert expected_info == render_call_dict['learner_program_enrollments'] @ddt.data( - ('aabbcc', ''), - ('', 'test_org') + ('', 'test_org'), + ('bad_key', '') ) @ddt.unpack @patch_render - def test_search_external_key_no_idp(self, user_key_input, idp_input, mocked_render): + def test_search_no_external_user_key(self, user_key, org_key, mocked_render): self.client.get(self.url, data={ - 'external_user_key': user_key_input, - 'IdPSelect': idp_input, + 'external_user_key': user_key, + 'org_key': org_key, }) - expected_errors = [ + 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!" - ] + ) render_call_dict = mocked_render.call_args[0][1] assert {} == render_call_dict['learner_program_enrollments'] - assert expected_errors == render_call_dict['errors'] + assert expected_error == render_call_dict['error'] @patch_render def test_search_external_user_not_connected(self, mocked_render): @@ -844,9 +850,12 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): ) self.client.get(self.url, data={ 'external_user_key': self.external_user_key, - 'IdPSelect': self.org_key_list[0] + 'org_key': self.org_key_list[0] }) expected_info = { + 'user': { + 'external_user_key': self.external_user_key, + }, 'enrollments': expected_enrollments } @@ -858,13 +867,11 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): external_user_key = 'not_in_system' self.client.get(self.url, data={ 'external_user_key': external_user_key, - 'IdPSelect': self.org_key_list[0], + 'org_key': self.org_key_list[0], }) - expected_errors = [ - 'No user found for external key {} for institution {}'.format( - external_user_key, self.org_key_list[0] - ) - ] + expected_error = 'No user found for external key {} for institution {}'.format( + external_user_key, self.org_key_list[0] + ) render_call_dict = mocked_render.call_args[0][1] - assert expected_errors == render_call_dict['errors'] + assert expected_error == render_call_dict['error'] diff --git a/lms/djangoapps/support/views/index.py b/lms/djangoapps/support/views/index.py index 002df54eb0..87a4d5e1c8 100644 --- a/lms/djangoapps/support/views/index.py +++ b/lms/djangoapps/support/views/index.py @@ -48,6 +48,11 @@ SUPPORT_INDEX_URLS = [ "name": _("Link Program Enrollments"), "description": _("Link LMS users to program enrollments"), }, + { + "url": reverse_lazy("support:program_enrollments_inspector"), + "name": _("Program Enrollments Inspector Tool"), + "description": _("Find information related to a learner's program enrollments"), + }, ] diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py index acf26fd8d3..13424eedf4 100644 --- a/lms/djangoapps/support/views/program_enrollments.py +++ b/lms/djangoapps/support/views/program_enrollments.py @@ -134,28 +134,35 @@ class ProgramEnrollmentsInspectorView(View): Search the data store for information about ProgramEnrollment and SSO linkage with the user. """ - errors = [] + search_error = '' edx_username_or_email = request.GET.get('edx_user', '').strip() - org_key = request.GET.get('IdPSelect', '').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, error = self._get_account_info(edx_username_or_email) - if error: - errors.append(error) + 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 + org_key, + selected_provider, ) if not learner_program_enrollments: - errors.append( - 'No user found for external key {} for institution {}'.format( - external_user_key, org_key - ) + 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: - errors.append( + 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 " @@ -165,38 +172,39 @@ class ProgramEnrollmentsInspectorView(View): return render_to_response( self.CONSOLE_TEMPLATE_PATH, { - 'errors': errors, + 'error': search_error, 'learner_program_enrollments': learner_program_enrollments, - 'org_keys': self._get_org_keys_with_idp(), + 'org_keys': sorted(saml_providers_with_org_key.keys()), } ) - def _get_org_keys_with_idp(self): + def _get_org_keys_and_idps(self): """ - From our Third_party_auth models, return a list - of organizations whose SAMLProviders are active and configured + From our Third_party_auth models, return a dictionary of + of organizations keys and their correspondingactive and configured SAMLProviders """ saml_providers = SAMLProviderConfig.objects.current_set().filter( enabled=True, organization__isnull=False ).select_related('organization') - return [saml_provider.organization.short_name for saml_provider in saml_providers] + return { + saml_provider.organization.short_name: saml_provider for saml_provider in saml_providers + } - def _get_account_info(self, username_or_email): + def _get_account_info(self, username_or_email, idp_provider=None): """ - Provided the edx account username or email, return edx account info - and program_enrollments_info. If we cannot identify the user, return - empty object and error. + Provided the edx account username or email, and the SAML provider selected, + return edx account info and program_enrollments_info. + If we cannot identify the user, return empty object and error. """ try: user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) - user_social_auth = None - try: - user_social_auth = UserSocialAuth.objects.get(user=user) - except UserSocialAuth.DoesNotExist: - pass - user_info = serialize_user_info(user, user_social_auth) + user_social_auths = None + user_social_auths = UserSocialAuth.objects.filter(user=user) + if idp_provider: + user_social_auths = user_social_auths.filter(provider=idp_provider.backend_name) + user_info = serialize_user_info(user, user_social_auths) enrollments = self._get_enrollments(user=user) result = {'user': user_info} if enrollments: @@ -207,7 +215,7 @@ class ProgramEnrollmentsInspectorView(View): except User.DoesNotExist: return {}, 'Could not find edx account with {}'.format(username_or_email) - def _get_external_user_info(self, external_user_key, org_key): + def _get_external_user_info(self, external_user_key, org_key, idp_provider=None): """ Provided the external_user_key and org_key, return edx account info and program_enrollments_info if any. If we cannot identify the data, @@ -229,17 +237,19 @@ class ProgramEnrollmentsInspectorView(View): # We cannot identify edX user from external_user_key and org_key pair pass - if found_user: - try: - user_social_auth = UserSocialAuth.objects.get(user=found_user) - except UserSocialAuth.DoesNotExist: - user_social_auth = None - user_info = serialize_user_info(found_user, user_social_auth) - result['user'] = user_info - result['id_verification'] = IDVerificationService.user_status(found_user) enrollments = self._get_enrollments(external_user_key=external_user_key) if enrollments: result['enrollments'] = enrollments + if found_user: + user_social_auths = UserSocialAuth.objects.filter(user=found_user) + if idp_provider: + user_social_auths = user_social_auths.filter(provider=idp_provider.backend_name) + user_info = serialize_user_info(found_user, user_social_auths) + result['user'] = user_info + result['id_verification'] = IDVerificationService.user_status(found_user) + elif 'enrollments' in result: + result['user'] = {'external_user_key': external_user_key} + return result def _get_enrollments(self, user=None, external_user_key=None): diff --git a/lms/templates/support/program_enrollments_inspector.html b/lms/templates/support/program_enrollments_inspector.html index fe7c162909..35d7f0ec5f 100644 --- a/lms/templates/support/program_enrollments_inspector.html +++ b/lms/templates/support/program_enrollments_inspector.html @@ -21,16 +21,15 @@ from openedx.core.djangolib.js_utils import js_escaped_string <%block name="content">
-

Program Enrollments Inspector

+

Program Enrollments Inspector

${static.renderReact( component="ProgramEnrollmentsInspectorPage", - id="entitlement-support-page", + id="program-enrollments-inspector-page", props={ - 'errors': errors, + 'error': error, 'learnerInfo': learner_program_enrollments, 'orgKeys': org_keys } - ) - } + )}