diff --git a/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx b/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx new file mode 100644 index 0000000000..8467de0dd2 --- /dev/null +++ b/lms/djangoapps/support/static/support/jsx/program_enrollments/inspector.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, InputText, StatusAlert, InputSelect } from '@edx/paragon'; + +export const ProgramEnrollmentsInspectorPage = props => ( +
+); + +ProgramEnrollmentsInspectorPage.propTypes = { + successes: PropTypes.arrayOf(PropTypes.string), + errors: PropTypes.arrayOf(PropTypes.string), + learnerInfo: PropTypes.string, + orgKeys: PropTypes.arrayOf(PropTypes.object), +}; + +ProgramEnrollmentsInspectorPage.defaultProps = { + successes: [], + errors: [], + learnerInfo: '', + orgKeys: [], +}; diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 41d0366fd6..c6a529f72b 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -8,7 +8,7 @@ import itertools import json import re from datetime import datetime, timedelta -from uuid import uuid4, UUID +from uuid import UUID, uuid4 import ddt import six @@ -17,15 +17,19 @@ from django.db.models import signals from django.http import HttpResponse from django.urls import reverse from mock import patch +from organizations.tests.factories import OrganizationFactory from pytz import UTC +from social_django.models import UserSocialAuth from common.test.utils import disable_signal from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory +from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory from lms.djangoapps.verify_student.models import VerificationDeadline from student.models import ENROLLED_TO_ENROLLED, CourseEnrollment, CourseEnrollmentAttribute, ManualEnrollmentAudit from student.roles import GlobalStaff, SupportStaffRole from student.tests.factories import CourseEnrollmentFactory, UserFactory +from third_party_auth.tests.factories import SAMLProviderConfigFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -539,3 +543,238 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase): msg = u"All linking lines must be in the format 'external_user_key,lms_username'" render_call_dict = mocked_render.call_args[0][1] assert render_call_dict['errors'] == [msg] + + +class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase): + """ + View tests for Program Enrollments Inspector + """ + patch_render = patch( + 'support.views.program_enrollments.render_to_response', + return_value=HttpResponse(), + autospec=True, + ) + + def setUp(self): + super(ProgramEnrollmentsInspectorViewTests, self).setUp() + self.url = reverse("support:program_enrollments_inspector") + 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_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)) + assert response.status_code == 200 + assert expected_organization_serialized in content + assert '"learnerInfo": {}' in content + + 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='{0}:{1}'.format(org_key, external_user_key), + provider=org_key + ) + user_info['external_user_key'] = external_user_key + user_info['SSO'] = { + 'uid': user_social_auth.uid, + 'provider': user_social_auth.provider + } + 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 + """ + expected_enrollments = [] + for program_uuid in program_uuids: + expected_enrollment = {} + expected_course_enrollment = {} + course_enrollment = None + program_enrollment = ProgramEnrollmentFactory.create( + external_user_key=external_user_key, + program_uuid=program_uuid, + user=edx_user + ) + expected_enrollment['program_enrollment'] = { + 'created': self._serialize_datetime( + program_enrollment.created + ), + 'modified': self._serialize_datetime( + program_enrollment.modified + ), + 'program_uuid': program_enrollment.program_uuid, + 'external_user_key': external_user_key, + 'status': program_enrollment.status + } + + 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 + ) + expected_course_enrollment = { + 'course_id': str(course_enrollment.course_id), + 'is_active': course_enrollment.is_active, + 'mode': course_enrollment.mode, + } + + program_course_enrollment = ProgramCourseEnrollmentFactory.create( + program_enrollment=program_enrollment, + course_key=course_id, + course_enrollment=course_enrollment, + status='active', + ) + expected_program_course_enrollment = { + 'created': self._serialize_datetime( + program_course_enrollment.created + ), + 'modified': self._serialize_datetime( + program_course_enrollment.modified + ), + 'status': program_course_enrollment.status, + 'course_key': str(program_course_enrollment.course_key), + } + if expected_course_enrollment: + expected_program_course_enrollment['course_enrollment'] = expected_course_enrollment + + expected_enrollment.setdefault('program_course_enrollments', []).append( + expected_program_course_enrollment + ) + + expected_enrollments.append(expected_enrollment) + + return expected_enrollments + + @patch_render + def test_search_username_well_connected_user(self, mocked_render): + created_user, expected_user_info = self._construct_user( + 'test_user_connected', + self.org_key_list[0], + self.external_user_key + ) + expected_enrollments = self._construct_enrollments( + [self.program_uuid], + [self.course.id], + self.external_user_key, + created_user + ) + self.client.get(self.url, data={ + 'edx_user': created_user.username + }) + expected_info = { + 'user': expected_user_info, + 'enrollments': expected_enrollments + } + + render_call_dict = mocked_render.call_args[0][1] + assert expected_info == render_call_dict['learner_program_enrollments'] + + @patch_render + 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 + }) + expected_info = { + 'user': expected_user_info + } + + render_call_dict = mocked_render.call_args[0][1] + assert expected_info == render_call_dict['learner_program_enrollments'] + + @patch_render + def test_search_username_user_no_enrollment(self, mocked_render): + created_user, expected_user_info = self._construct_user( + 'user_connected', + self.org_key_list[0], + self.external_user_key + ) + self.client.get(self.url, data={ + 'edx_user': created_user.email + }) + expected_info = { + 'user': expected_user_info + } + + render_call_dict = mocked_render.call_args[0][1] + assert expected_info == render_call_dict['learner_program_enrollments'] + + @patch_render + def test_search_username_user_no_course_enrollment(self, mocked_render): + 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, + ) + self.client.get(self.url, data={ + 'edx_user': created_user.email + }) + expected_info = { + 'user': expected_user_info, + 'enrollments': expected_enrollments + } + + render_call_dict = mocked_render.call_args[0][1] + assert expected_info == render_call_dict['learner_program_enrollments'] + + @patch_render + def test_search_username_user_not_connected_with_enrollments(self, mocked_render): + created_user, expected_user_info = self._construct_user( + 'user_not_connected' + ) + self._construct_enrollments( + [self.program_uuid], + [], + self.external_user_key, + ) + self.client.get(self.url, data={ + 'edx_user': created_user.email + }) + expected_info = { + 'user': expected_user_info, + } + + render_call_dict = mocked_render.call_args[0][1] + assert expected_info == render_call_dict['learner_program_enrollments'] diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 49b3bf2eee..6dcbe5a063 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -12,8 +12,8 @@ from support.views.enrollments import EnrollmentSupportListView, EnrollmentSuppo from support.views.feature_based_enrollments import FeatureBasedEnrollmentsSupportView from support.views.index import index from support.views.manage_user import ManageUserDetailView, ManageUserSupportView +from support.views.program_enrollments import LinkProgramEnrollmentSupportView, ProgramEnrollmentsInspectorView from support.views.refund import RefundSupportView -from support.views.program_enrollments import LinkProgramEnrollmentSupportView COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view() @@ -41,5 +41,10 @@ urlpatterns = [ FeatureBasedEnrollmentsSupportView.as_view(), name="feature_based_enrollments" ), - url(r'link_program_enrollments/?$', LinkProgramEnrollmentSupportView.as_view(), name='link_program_enrollments') + url(r'link_program_enrollments/?$', LinkProgramEnrollmentSupportView.as_view(), name='link_program_enrollments'), + url( + r'program_enrollments_inspector/?$', + ProgramEnrollmentsInspectorView.as_view(), + name='program_enrollments_inspector' + ) ] diff --git a/lms/djangoapps/support/views/certificate.py b/lms/djangoapps/support/views/certificate.py index 7f0952f03f..ab60ffec39 100644 --- a/lms/djangoapps/support/views/certificate.py +++ b/lms/djangoapps/support/views/certificate.py @@ -3,9 +3,9 @@ Certificate tool in the student support app. """ -from six.moves.urllib.parse import unquote, quote_plus # pylint: disable=import-error from django.utils.decorators import method_decorator from django.views.generic import View +from six.moves.urllib.parse import quote_plus, unquote from edxmako.shortcuts import render_to_response from support.decorators import require_support_permission diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py index 54615d84a2..5aa9a649f4 100644 --- a/lms/djangoapps/support/views/program_enrollments.py +++ b/lms/djangoapps/support/views/program_enrollments.py @@ -6,14 +6,22 @@ Support tool for changing course enrollments. import csv from uuid import UUID +from django.contrib.auth.models import User +from django.db.models import Q from django.utils.decorators import method_decorator from django.views.generic import View +from social_django.models import UserSocialAuth from edxmako.shortcuts import render_to_response -from lms.djangoapps.program_enrollments.api import link_program_enrollments +from lms.djangoapps.program_enrollments.api import ( + fetch_program_enrollments_by_student, + link_program_enrollments +) from lms.djangoapps.support.decorators import require_support_permission +from third_party_auth.models import SAMLProviderConfig TEMPLATE_PATH = 'support/link_program_enrollments.html' +DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S' class LinkProgramEnrollmentSupportView(View): @@ -98,3 +106,153 @@ class LinkProgramEnrollmentSupportView(View): ] errors = [message for message in link_errors.values()] return successes, errors + + +class ProgramEnrollmentsInspectorView(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. + """ + errors = [] + edx_username_or_email = request.GET.get('edx_user', '').strip() + org_key = request.GET.get('IdPSelect', '').strip() + external_user_key = request.GET.get('external_user_key', '').strip() + learner_program_enrollments = {} + if edx_username_or_email: + learner_program_enrollments, error = self._get_account_info(edx_username_or_email) + if error: + errors.append(error) + elif org_key and external_user_key: + learner_program_enrollments = {} + elif not external_user_key and org_key: + errors.append( + 'You must provide either the edX username or email, or the ' + 'Learner Account Provider and External Key pair to do search!' + ) + + return render_to_response( + self.CONSOLE_TEMPLATE_PATH, + { + 'successes': [], + 'errors': errors, + 'learner_program_enrollments': learner_program_enrollments, + 'org_keys': self._get_org_keys_with_idp(), + } + ) + + def _get_org_keys_with_idp(self): + """ + From our Third_party_auth models, return a list + of organizations whose SAMLProviders are active and configured + """ + 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] + + def _get_account_info(self, username_or_email): + """ + 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. + """ + user_info = {} + external_key = None + try: + user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + user_info['username'] = user.username + user_info['email'] = user.email + try: + user_social_auth = UserSocialAuth.objects.get(user=user) + _, 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 + } + except UserSocialAuth.DoesNotExist: + pass + + enrollments = self._get_enrollments(user=user) + result = {'user': user_info} + if enrollments: + result['enrollments'] = enrollments + + return result, '' + except User.DoesNotExist: + return {}, 'Could not find edx account with {}'.format(username_or_email) + + def _get_enrollments(self, user=None, external_user_key=None): + """ + With the user or external_user_key passed in, + return an array of dictionariers with corresponding ProgramEnrollments + and ProgramCourseEnrollments all serialized for view + """ + program_enrollments = fetch_program_enrollments_by_student( + user=user, + external_user_key=external_user_key + ).prefetch_related('program_course_enrollments') + + enrollments_by_program_uuid = {} + for program_enrollment in program_enrollments: + serialized_program_enrollment = self._serialize_program_enrollment(program_enrollment) + enrollment_item = { + 'program_enrollment': serialized_program_enrollment + } + program_course_enrollments = program_enrollment.program_course_enrollments.all() + for program_course_enrollment in program_course_enrollments.select_related( + 'course_enrollment' + ): + serialized_program_course_enrollment = self._serialize_program_course_enrollment( + program_course_enrollment + ) + enrollment_item.setdefault('program_course_enrollments', []).append( + serialized_program_course_enrollment + ) + enrollments_by_program_uuid[program_enrollment.program_uuid] = enrollment_item + + return list(enrollments_by_program_uuid.values()) + + def _serialize_program_enrollment(self, program_enrollment): + if not program_enrollment: + return {} + + return { + 'created': program_enrollment.created.strftime(DATETIME_FORMAT), + 'modified': program_enrollment.modified.strftime(DATETIME_FORMAT), + 'program_uuid': str(program_enrollment.program_uuid), + 'external_user_key': program_enrollment.external_user_key, + 'status': program_enrollment.status + } + + def _serialize_program_course_enrollment(self, program_course_enrollment): + """ + Return a dictionary of ProgramCourseEnrollment serialized + """ + if not program_course_enrollment: + return {} + + course_enrollment = program_course_enrollment.course_enrollment + return { + 'created': program_course_enrollment.created.strftime(DATETIME_FORMAT), + 'modified': program_course_enrollment.modified.strftime(DATETIME_FORMAT), + 'course_enrollment': { + 'course_id': str(course_enrollment.course_id), + 'is_active': course_enrollment.is_active, + 'mode': course_enrollment.mode, + }, + 'status': program_course_enrollment.status, + 'course_key': str(program_course_enrollment.course_key), + } diff --git a/lms/templates/support/program_enrollments_inspector.html b/lms/templates/support/program_enrollments_inspector.html new file mode 100644 index 0000000000..a6b5be932e --- /dev/null +++ b/lms/templates/support/program_enrollments_inspector.html @@ -0,0 +1,37 @@ +<%page expression_filter="h"/> + +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import js_escaped_string +%> + +## Override the default styles_version to use Bootstrap +<%! main_css = "css/bootstrap/lms-main.css" %> + +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../main.html" /> + +<%block name="js_extra"> +%block> + +<%block name="pagetitle"> + ${_("Program Enrollments Inspector")} +%block> + +<%block name="content"> +