diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 2833b1cf34..443f420b78 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -648,7 +648,7 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase): '0001,learner-01,apple,orange\n0002,learner-02,purple', # extra fields '\t0001 , \t learner-01 \n 0002 , learner-02 ', # whitespace ) - @patch('lms.djangoapps.support.views.program_enrollments.link_program_enrollments') + @patch('lms.djangoapps.support.views.utils.link_program_enrollments') def test_text(self, text, mocked_link): self.client.post(self.url, data={ 'program_uuid': self.program_uuid, @@ -1189,3 +1189,183 @@ class FeatureBasedEnrollmentSupportApiViewTests(SupportViewTestCase): ) data = json.loads(response.content.decode('utf-8')) assert data == {} + + +@ddt.ddt +class LinkProgramEnrollmentSupportAPIViewTests(SupportViewTestCase): + """ + Tests for the link_program_enrollments support view. + """ + _url = reverse("support:link_program_enrollments_details") + + def setUp(self): + """ + Make the user support staff. + """ + super().setUp() + SupportStaffRole().add_users(self.user) + self.program_uuid = str(uuid4()) + self.username_pair_text = '0001,user-0001\n0002,user-02' + + def _setup_user_from_username(self, username): + """ + Setup a user from the passed in username. + If username passed in is falsy, return None + """ + created_user = None + if username: + created_user = UserFactory(username=username, password=self.PASSWORD) + return created_user + + def _setup_enrollments(self, external_user_key, linked_user=None): + """ + Create enrollments for testing linking. + The enrollments can be created with already linked edX user. + """ + program_enrollment = ProgramEnrollmentFactory.create( + external_user_key=external_user_key, + program_uuid=self.program_uuid, + user=linked_user + ) + course_enrollment = None + if linked_user: + course_enrollment = CourseEnrollmentFactory.create( + course_id=self.course.id, + user=linked_user, + mode=CourseMode.MASTERS, + is_active=True + ) + program_course_enrollment = ProgramCourseEnrollmentFactory.create( + program_enrollment=program_enrollment, + course_key=self.course.id, + course_enrollment=course_enrollment, + status='active' + ) + return program_enrollment, program_course_enrollment + + def test_invalid_uuid(self): + """ + Tests if enrollment linkages are refused for an invalid uuid + """ + response = self.client.post(self._url, data={ + 'program_uuid': 'notauuid', + 'username_pair_text': self.username_pair_text, + }) + msg = "Supplied program UUID 'notauuid' is not a valid UUID." + data = json.loads(response.content.decode('utf-8')) + assert data['errors'] == [msg] + + @ddt.data( + ('program_uuid', ''), + ('', 'username_pair_text'), + ('', '') + ) + @ddt.unpack + def test_missing_parameter(self, program_uuid, username_pair_text): + """ + Tests if enrollment linkages are refused for missing parameters + """ + error = ( + "You must provide both a program uuid " + "and a series of lines with the format " + "'external_user_key,lms_username'." + ) + response = self.client.post(self._url, data={ + 'program_uuid': program_uuid, + 'username_pair_text': username_pair_text + }) + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['errors'] == [error] + + @ddt.data( + '0001,learner-01\n0002,learner-02', # normal + '0001,learner-01,apple,orange\n0002,learner-02,purple', # extra fields + '\t0001 , \t learner-01 \n 0002 , learner-02 ', # whitespace + ) + @patch('lms.djangoapps.support.views.utils.link_program_enrollments') + def test_username_pair_text(self, username_pair_text, mocked_link): + """ + Tests if enrollment linkages are created for different types of + username_pair_text format + """ + response = self.client.post(self._url, data={ + 'program_uuid': self.program_uuid, + 'username_pair_text': username_pair_text, + }) + response_data = json.loads(response.content.decode('utf-8')) + mocked_link.assert_called_once() + mocked_link.assert_called_with( + UUID(self.program_uuid), + { + '0001': 'learner-01', + '0002': 'learner-02', + } + ) + success = ["('0001', 'learner-01')", "('0002', 'learner-02')"] + assert response_data['successes'] == success + mocked_link.reset_mock() + + def test_invalid_username_pair_text(self): + """ + Tests if enrollment linkages are refused for invalid types of + username_pair_text format + """ + username_pair_text = 'garbage_text' + response = self.client.post(self._url, data={ + 'program_uuid': self.program_uuid, + 'username_pair_text': username_pair_text, + }) + msg = "All linking lines must be in the format 'external_user_key,lms_username'" + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['errors'] == [msg] + + @ddt.data( + ('linked_user', None), + ('linked_user', 'original_user') + ) + @ddt.unpack + def test_linking_program_enrollment_with_username(self, username, original_username): + """ + Tests if enrollment linkages are created for valid usernames + """ + external_user_key = '0001' + linked_user = self._setup_user_from_username(username) + original_user = self._setup_user_from_username(original_username) + program_enrollment, program_course_enrollment = self._setup_enrollments( + external_user_key, + original_user + ) + response = self.client.post(self._url, data={ + 'program_uuid': self.program_uuid, + 'username_pair_text': external_user_key + ',' + username + }) + response_data = json.loads(response.content.decode('utf-8')) + expected_success = f"('{external_user_key}', '{username}')" + assert response_data['successes'] == [expected_success] + program_enrollment.refresh_from_db() + assert program_enrollment.user == linked_user + program_course_enrollment.refresh_from_db() + assert program_course_enrollment.course_enrollment.user == linked_user + + @ddt.data( + ('', None), + ) + @ddt.unpack + def test_linking_program_enrollment_without_username(self, username, original_username): + """ + Tests if enrollment linkages are refused for invalid usernames + """ + external_user_key = '0001' + linked_user = self._setup_user_from_username(username) + original_user = self._setup_user_from_username(original_username) + program_enrollment, program_course_enrollment = self._setup_enrollments( + external_user_key, + original_user + ) + response = self.client.post(self._url, data={ + 'program_uuid': self.program_uuid, + 'username_pair_text': external_user_key + ',' + username + }) + response_data = json.loads(response.content.decode('utf-8')) + error = "All linking lines must be in the format 'external_user_key,lms_username'" + assert response_data['errors'] == [error] diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index d9f05d4081..d15611c452 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -12,7 +12,11 @@ from .views.enrollments import EnrollmentSupportListView, EnrollmentSupportView from .views.feature_based_enrollments import FeatureBasedEnrollmentsSupportView, FeatureBasedEnrollmentSupportAPIView from .views.index import index from .views.manage_user import ManageUserDetailView, ManageUserSupportView -from .views.program_enrollments import LinkProgramEnrollmentSupportView, ProgramEnrollmentsInspectorView +from .views.program_enrollments import ( + LinkProgramEnrollmentSupportView, + LinkProgramEnrollmentSupportAPIView, + ProgramEnrollmentsInspectorView +) from .views.sso_records import SsoView COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view() @@ -45,8 +49,16 @@ urlpatterns = [ FeatureBasedEnrollmentSupportAPIView.as_view(), name="feature_based_enrollment_details" ), - re_path(r'link_program_enrollments/?$', LinkProgramEnrollmentSupportView.as_view(), - name='link_program_enrollments'), + re_path( + r'link_program_enrollments/?$', + LinkProgramEnrollmentSupportView.as_view(), + name='link_program_enrollments' + ), + re_path( + r'link_program_enrollments_details/?$', + LinkProgramEnrollmentSupportAPIView.as_view(), + name='link_program_enrollments_details' + ), re_path( r'program_enrollments_inspector/?$', ProgramEnrollmentsInspectorView.as_view(), diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py index 4cbc253939..e0c38199b1 100644 --- a/lms/djangoapps/support/views/program_enrollments.py +++ b/lms/djangoapps/support/views/program_enrollments.py @@ -2,14 +2,15 @@ Support tool for changing course enrollments. """ - -import csv -from uuid import UUID - from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db.models import Q from django.utils.decorators import method_decorator from django.views.generic import View +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.views import APIView +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated from social_django.models import UserSocialAuth from common.djangoapps.edxmako.shortcuts import render_to_response @@ -17,7 +18,6 @@ from common.djangoapps.third_party_auth.models import SAMLProviderConfig from lms.djangoapps.program_enrollments.api import ( fetch_program_enrollments_by_student, get_users_by_external_keys_and_org_key, - link_program_enrollments ) from lms.djangoapps.program_enrollments.exceptions import ( BadOrganizationShortNameException, @@ -26,6 +26,7 @@ from lms.djangoapps.program_enrollments.exceptions import ( from lms.djangoapps.support.decorators import require_support_permission from lms.djangoapps.support.serializers import ProgramEnrollmentSerializer, serialize_user_info from lms.djangoapps.verify_student.services import IDVerificationService +from lms.djangoapps.support.views.utils import validate_and_link_program_enrollments TEMPLATE_PATH = 'support/link_program_enrollments.html' DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S' @@ -60,7 +61,7 @@ class LinkProgramEnrollmentSupportView(View): """ program_uuid = request.POST.get('program_uuid', '').strip() text = request.POST.get('text', '') - successes, errors = self._validate_and_link(program_uuid, text) + successes, errors = validate_and_link_program_enrollments(program_uuid, text) return render_to_response( TEMPLATE_PATH, { @@ -71,48 +72,48 @@ class LinkProgramEnrollmentSupportView(View): } ) - @staticmethod - def _validate_and_link(program_uuid_string, linkage_text): - """ - Validate arguments, and if valid, call `link_program_enrollments`. - Returns: (successes, errors) - where successes and errors are both list[str] +class LinkProgramEnrollmentSupportAPIView(APIView): + """ + Support-only API View for linking learner enrollments by support staff. + """ + authentication_classes = ( + JwtAuthentication, SessionAuthentication + ) + permission_classes = ( + IsAuthenticated, + ) + + @method_decorator(require_support_permission) + def post(self, request): """ - if not (program_uuid_string and linkage_text): - error = ( - "You must provide both a program uuid " - "and a series of lines with the format " - "'external_user_key,lms_username'." - ) - return [], [error] - try: - program_uuid = UUID(program_uuid_string) - except ValueError: - return [], [ - f"Supplied program UUID '{program_uuid_string}' is not a valid UUID." - ] - reader = csv.DictReader( - linkage_text.splitlines(), fieldnames=('external_key', 'username') - ) - ext_key_to_username = { - (item.get('external_key') or '').strip(): (item['username'] or '').strip() - for item in reader + Links learner enrollments by support staff + * Example Request: + - POST / support / link_program_enrollments_details/ + * Sample Payload + { + program_uuid: < program_uuid > , + username_pair_text: 'external_user_key,lms_username' + } + * Example Response: + { + program_uuid: < program_uuid>, + username_pair_text: 'external_user_key,lms_username' + successes: 'Success messages if Linkages are created', + errors: 'Error messages if there is no linkages' + } + """ + + program_uuid = request.POST.get('program_uuid', '').strip() + username_pair_text = request.POST.get('username_pair_text', '') + successes, errors = validate_and_link_program_enrollments(program_uuid, username_pair_text) + data = { + 'successes': successes, + 'errors': errors, + 'program_uuid': program_uuid, + 'username_pair_text': username_pair_text, } - if not (all(ext_key_to_username.keys()) and all(ext_key_to_username.values())): - return [], [ - "All linking lines must be in the format 'external_user_key,lms_username'" - ] - link_errors = link_program_enrollments( - program_uuid, ext_key_to_username - ) - successes = [ - str(item) - for item in ext_key_to_username.items() - if item not in link_errors - ] - errors = list(link_errors.values()) - return successes, errors + return Response(data) class ProgramEnrollmentsInspectorView(View): diff --git a/lms/djangoapps/support/views/utils.py b/lms/djangoapps/support/views/utils.py index d93ef69366..6a5008866d 100644 --- a/lms/djangoapps/support/views/utils.py +++ b/lms/djangoapps/support/views/utils.py @@ -1,6 +1,9 @@ """ Various utility methods used by support app views. """ +import csv +from uuid import UUID + from django.core.exceptions import ObjectDoesNotExist from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -8,6 +11,9 @@ from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from lms.djangoapps.program_enrollments.api import ( + link_program_enrollments +) def get_course_duration_info(course_key): @@ -42,3 +48,46 @@ def get_course_duration_info(course_key): except (ObjectDoesNotExist, InvalidKeyError): return {} + + +def validate_and_link_program_enrollments(program_uuid_string, linkage_text): + """ + Validate arguments, and if valid, call `link_program_enrollments`. + + Returns: (successes, errors) + where successes and errors are both list[str] + """ + if not (program_uuid_string and linkage_text): + error = ( + "You must provide both a program uuid " + "and a series of lines with the format " + "'external_user_key,lms_username'." + ) + return [], [error] + try: + program_uuid = UUID(program_uuid_string) + except ValueError: + return [], [ + f"Supplied program UUID '{program_uuid_string}' is not a valid UUID." + ] + reader = csv.DictReader( + linkage_text.splitlines(), fieldnames=('external_key', 'username') + ) + ext_key_to_username = { + (item.get('external_key') or '').strip(): (item['username'] or '').strip() + for item in reader + } + if not (all(ext_key_to_username.keys()) and all(ext_key_to_username.values())): + return [], [ + "All linking lines must be in the format 'external_user_key,lms_username'" + ] + link_errors = link_program_enrollments( + program_uuid, ext_key_to_username + ) + successes = [ + str(item) + for item in ext_key_to_username.items() + if item[0] not in link_errors.keys() + ] + errors = [message for message in link_errors.values()] # lint-amnesty, pylint: disable=unnecessary-comprehension + return successes, errors