diff --git a/lms/djangoapps/program_enrollments/link_program_enrollments.py b/lms/djangoapps/program_enrollments/link_program_enrollments.py new file mode 100644 index 0000000000..116a9f31ef --- /dev/null +++ b/lms/djangoapps/program_enrollments/link_program_enrollments.py @@ -0,0 +1,198 @@ +""" Function to link program enrollments and external_student_keys to an LMS user """ +import logging +from uuid import UUID + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from lms.djangoapps.program_enrollments.models import ProgramEnrollment +from student.models import CourseEnrollmentException + +logger = logging.getLogger(__name__) +User = get_user_model() + +NO_PROGRAM_ENROLLMENT_TPL = ( + u'No program enrollment found for program uuid={program_uuid} and external student ' + 'key={external_student_key}' +) +NO_LMS_USER_TPL = u'No user found with username {}' +COURSE_ENROLLMENT_ERR_TPL = ( + u'Failed to enroll user {user} with waiting program course enrollment for course {course}' +) +EXISTING_USER_TPL = ( + u'Program enrollment with external_student_key={external_student_key} is already linked to ' + u'{account_relation} account username={username}' +) + + +@transaction.atomic +def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames): + u""" + Utility function to link ProgramEnrollments to LMS Users + + Arguments: + -program_uuid: the program for which we are linking program enrollments + -external_keys_to_usernames: dict mapping `external_user_keys` to LMS usernames. + + Returns: + { + (external_key, username): Error message if there was an error + } + + Raises: ValueError if None is included in external_keys_to_usernames + + This function will look up program enrollments and users, and update the program + enrollments with the matching user. If the program enrollment has course enrollments, we + will enroll the user into their waiting program courses. + + For each external_user_key:lms_username, if: + - The user is not found + - No enrollment is found for the given program and external_user_key + - The enrollment already has a user + An error message will be logged, and added to a dictionary of error messages keyed by + (external_key, username). The input will be skipped. All other inputs will be processed and + enrollments updated, and then the function will return the dictionary of error messages. + + If there is an error while enrolling a user in a waiting program course enrollment, the + error will be logged, and added to the returned error dictionary, and we will roll back all + transactions for that user so that their db state will be the same as it was before this + function was called, to prevent program enrollments to be in a state where they have an LMS + user but still have waiting course enrollments. All other inputs will be processed + normally. + """ + _validate_inputs(program_uuid, external_keys_to_usernames) + errors = {} + program_enrollments = get_program_enrollments(program_uuid, external_keys_to_usernames.keys()) + users = get_lms_users(external_keys_to_usernames.values()) + for item in external_keys_to_usernames.items(): + external_student_key, username = item + + user = users.get(username) + error_message = None + if not user: + error_message = NO_LMS_USER_TPL.format(username) + + program_enrollment = program_enrollments.get(external_student_key) + if not program_enrollment: + error_message = NO_PROGRAM_ENROLLMENT_TPL.format( + program_uuid=program_uuid, + external_student_key=external_student_key + ) + elif program_enrollment.user: + error_message = get_existing_user_message(program_enrollment, user) + + if error_message: + logger.warning(error_message) + errors[item] = error_message + continue + + try: + with transaction.atomic(): + link_program_enrollment_to_lms_user(program_enrollment, user) + except (CourseEnrollmentException, IntegrityError) as e: + logger.exception(u"Rolling back all operations for {}:{}".format( + external_student_key, + username, + )) + error_message = type(e).__name__ + if str(e): + error_message += ': ' + error_message += str(e) + errors[item] = error_message + return errors + + +def _validate_inputs(program_uuid, external_keys_to_usernames): + if None in external_keys_to_usernames or None in external_keys_to_usernames.values(): + raise ValueError('external_user_key or username cannot be None') + UUID(str(program_uuid)) # raises ValueError if invalid + + +def get_program_enrollments(program_uuid, external_student_keys): + """ + Does a bulk read of ProgramEnrollments for a given program and list of external student keys + and returns a dict keyed by external student key + """ + program_enrollments = ProgramEnrollment.bulk_read_by_student_key( + program_uuid, + external_student_keys + ).prefetch_related( + 'program_course_enrollments' + ).select_related('user') + return { + program_enrollment.external_user_key: program_enrollment + for program_enrollment in program_enrollments + } + + +def get_lms_users(lms_usernames): + """ + Does a bulk read of Users by username and returns a dict keyed by username + """ + return { + user.username: user + for user in User.objects.filter(username__in=lms_usernames) + } + + +def link_program_enrollment_to_lms_user(program_enrollment, user): + """ + Attempts to link the given program enrollment to the given user + If the enrollment has any program course enrollments, enroll the user in those courses as well + + Raises: CourseEnrollmentException if there is an error enrolling user in a waiting + program course enrollment + IntegrityError if we try to create invalid records. + """ + try: + _link_program_enrollment(program_enrollment, user) + _link_course_enrollments(program_enrollment, user) + except IntegrityError: + logger.exception("Integrity error while linking program enrollments") + raise + + +def _link_program_enrollment(program_enrollment, user): + """ + Links program enrollment to user. + + Raises IntegrityError if ProgramEnrollment is invalid + """ + logger.info(u'Linking external student key {} and user {}'.format( + program_enrollment.external_user_key, + user.username + )) + program_enrollment.user = user + program_enrollment.save() + + +def _link_course_enrollments(program_enrollment, user): + """ + Enrolls user in waiting program course enrollments + + Raises: + IntegrityError if a constraint is violated + CourseEnrollmentException if there is an issue enrolling the user in a course + """ + try: + for program_course_enrollment in program_enrollment.program_course_enrollments.all(): + program_course_enrollment.enroll(user) + except CourseEnrollmentException as e: + error_message = COURSE_ENROLLMENT_ERR_TPL.format( + user=user.username, + course=program_course_enrollment.course_key + ) + logger.exception(error_message) + raise type(e)(error_message) + + +def get_existing_user_message(program_enrollment, user): + """ + Creates an error message that the specified program enrollment is already linked to an lms user + """ + existing_username = program_enrollment.user.username + external_student_key = program_enrollment.external_user_key + return EXISTING_USER_TPL.format( + external_student_key=external_student_key, + account_relation='target' if program_enrollment.user.id == user.id else 'a different', + username=existing_username, + ) diff --git a/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py index 14c1236263..c0c8e9c13d 100644 --- a/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py +++ b/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py @@ -3,21 +3,13 @@ import logging from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError -from django.db import IntegrityError, transaction -from lms.djangoapps.program_enrollments.models import ProgramEnrollment -from student.models import CourseEnrollmentException +from lms.djangoapps.program_enrollments.link_program_enrollments import link_program_enrollments_to_lms_users logger = logging.getLogger(__name__) User = get_user_model() INCORRECT_PARAMETER_TPL = u'incorrectly formatted argument {}, must be in form :' DUPLICATE_KEY_TPL = u'external user key {} provided multiple times' -NO_PROGRAM_ENROLLMENT_TPL = (u'No program enrollment found for program uuid={program_uuid} and external student ' - 'key={external_student_key}') -NO_LMS_USER_TPL = u'No user found with username {}' -COURSE_ENROLLMENT_ERR_TPL = u'Failed to enroll user {user} with waiting program course enrollment for course {course}' -EXISTING_USER_TPL = (u'Program enrollment with external_student_key={external_student_key} is already linked to ' - u'{account_relation} account username={username}') class Command(BaseCommand): @@ -67,33 +59,12 @@ class Command(BaseCommand): ) # pylint: disable=arguments-differ - @transaction.atomic def handle(self, program_uuid, user_items, *args, **options): ext_keys_to_usernames = self.parse_user_items(user_items) - program_enrollments = self.get_program_enrollments(program_uuid, ext_keys_to_usernames.keys()) - users = self.get_lms_users(ext_keys_to_usernames.values()) - for external_student_key, username in ext_keys_to_usernames.items(): - program_enrollment = program_enrollments.get(external_student_key) - if not program_enrollment: - logger.warning(NO_PROGRAM_ENROLLMENT_TPL.format( - program_uuid=program_uuid, - external_student_key=external_student_key - )) - continue - - user = users.get(username) - if not user: - logger.warning(NO_LMS_USER_TPL.format(username)) - continue - try: - with transaction.atomic(): - self.link_program_enrollment(program_enrollment, user) - except (CourseEnrollmentException, IntegrityError): - logger.exception(u"Rolling back all operations for {}:{}".format( - external_student_key, - username, - )) - continue # transaction rolled back + try: + link_program_enrollments_to_lms_users(program_uuid, ext_keys_to_usernames) + except Exception as e: + raise CommandError(e) def parse_user_items(self, user_items): """ @@ -116,91 +87,3 @@ class Command(BaseCommand): result[external_user_key] = lms_username return result - - def get_program_enrollments(self, program_uuid, external_student_keys): - """ - Does a bulk read of ProgramEnrollments for a given program and list of external student keys - and returns a dict keyed by external student key - """ - program_enrollments = ProgramEnrollment.bulk_read_by_student_key( - program_uuid, - external_student_keys - ).prefetch_related( - 'program_course_enrollments' - ).select_related('user') - return { - program_enrollment.external_user_key: program_enrollment - for program_enrollment in program_enrollments - } - - def get_lms_users(self, lms_usernames): - """ - Does a bulk read of Users by username and returns a dict keyed by username - """ - return { - user.username: user - for user in User.objects.filter(username__in=lms_usernames) - } - - def link_program_enrollment(self, program_enrollment, user): - """ - Attempts to link the given program enrollment to the given user - If the enrollment has any program course enrollments, enroll the user in those courses as well - - Raises: CourseEnrollmentException if there is an error enrolling user in a waiting - program course enrollment - IntegrityError if we try to create invalid records. - """ - try: - self._link_program_enrollment(program_enrollment, user) - self._link_course_enrollments(program_enrollment, user) - except IntegrityError: - logger.exception("Integrity error while linking program enrollments") - raise - - def _link_program_enrollment(self, program_enrollment, user): - """ - Links program enrollment to user. - - Raises IntegrityError if ProgramEnrollment is invalid - """ - if program_enrollment.user: - logger.warning(get_existing_user_message(program_enrollment, user)) - return - logger.info(u'Linking external student key {} and user {}'.format( - program_enrollment.external_user_key, - user.username - )) - program_enrollment.user = user - program_enrollment.save() - - def _link_course_enrollments(self, program_enrollment, user): - """ - Enrolls user in waiting program course enrollments - - Raises: - IntegrityError if a constraint is violated - CourseEnrollmentException if there is an issue enrolling the user in a course - """ - try: - for program_course_enrollment in program_enrollment.program_course_enrollments.all(): - program_course_enrollment.enroll(user) - except CourseEnrollmentException: - logger.exception(COURSE_ENROLLMENT_ERR_TPL.format( - user=user.username, - course=program_course_enrollment.course_key - )) - raise - - -def get_existing_user_message(program_enrollment, user): - """ - Creates an error message that the specified program enrollment is already linked to an lms user - """ - existing_username = program_enrollment.user.username - external_student_key = program_enrollment.external_user_key - return EXISTING_USER_TPL.format( - external_student_key=external_student_key, - account_relation='target' if program_enrollment.user.id == user.id else 'a different', - username=existing_username, - ) diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py index 24ad84c64a..db11d8b041 100644 --- a/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py +++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py @@ -3,312 +3,27 @@ Tests for the link_program_enrollments management command. """ from __future__ import absolute_import -from uuid import uuid4 -from testfixtures import LogCapture - from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase -from edx_django_utils.cache import RequestCache +from lms.djangoapps.program_enrollments.tests.test_link_program_enrollments import TestLinkProgramEnrollmentsMixin from lms.djangoapps.program_enrollments.management.commands.link_program_enrollments import ( Command, INCORRECT_PARAMETER_TPL, DUPLICATE_KEY_TPL, - NO_PROGRAM_ENROLLMENT_TPL, - NO_LMS_USER_TPL, - COURSE_ENROLLMENT_ERR_TPL, - get_existing_user_message, ) -from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory -from opaque_keys.edx.keys import CourseKey -from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from student.tests.factories import UserFactory COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_program_enrollments' -class TestLinkProgramEnrollmentsMixin(object): - """ Utility methods and test data for testing the link_program_enrollments command """ - - @classmethod - def setUpTestData(cls): # pylint: disable=missing-docstring - cls.command = Command() - cls.program = uuid4() - cls.curriculum = uuid4() - cls.other_program = uuid4() - cls.fruit_course = CourseKey.from_string('course-v1:edX+Oranges+Apples') - cls.animal_course = CourseKey.from_string('course-v1:edX+Cats+Dogs') - CourseOverviewFactory.create(id=cls.fruit_course) - CourseOverviewFactory.create(id=cls.animal_course) - - def setUp(self): - self.user_1 = UserFactory.create() - self.user_2 = UserFactory.create() - - def tearDown(self): - RequestCache.clear_all_namespaces() - - def call_command(self, program_uuid, *user_info): - """ - Builds string arguments and calls the link_program_enrollments command - """ - command_args = [external_key + ":" + lms_username for external_key, lms_username in user_info] - call_command(self.command, program_uuid, *command_args) - - def _create_waiting_enrollment(self, program_uuid, external_user_key): - """ - Create a waiting program enrollment for the given program and external user key. - """ - return ProgramEnrollmentFactory.create( - user=None, - program_uuid=program_uuid, - curriculum_uuid=self.curriculum, - external_user_key=external_user_key, - ) - - def _create_waiting_course_enrollment(self, program_enrollment, course_key, status='active'): - """ - Create a waiting program course enrollment for the given program enrollment, course key, and optionally status - """ - return ProgramCourseEnrollmentFactory.create( - program_enrollment=program_enrollment, - course_key=course_key, - course_enrollment=None, - status=status, - ) - - def _assert_no_user(self, program_enrollment, refresh=True): - """ - Assert that the given program enrollment has no LMS user associated with it - """ - if refresh: - program_enrollment.refresh_from_db() - self.assertIsNone(program_enrollment.user) - - def _assert_no_program_enrollment(self, user, program_uuid, refresh=True): - """ - Assert that the given user is not enrolled in the given program - """ - if refresh: - user.refresh_from_db() - self.assertFalse(user.programenrollment_set.filter(program_uuid=program_uuid).exists()) - - def _assert_program_enrollment(self, user, program_uuid, external_user_key, refresh=True): - """ - Assert that the given user is enrolled in the given program with the given external user key - """ - if refresh: - user.refresh_from_db() - enrollment = user.programenrollment_set.get(program_uuid=program_uuid, external_user_key=external_user_key) - self.assertIsNotNone(enrollment) - - def _assert_user_enrolled_in_program_courses(self, user, program_uuid, *course_keys): - """ - Assert that the given user is has active enrollments in the given courses through the given program - """ - user.refresh_from_db() - program_enrollment = user.programenrollment_set.get(user=user, program_uuid=program_uuid) - all_course_enrollments = program_enrollment.program_course_enrollments - program_course_enrollments = all_course_enrollments.select_related( - 'course_enrollment__course' - ).filter( - course_enrollment__isnull=False - ) - course_enrollments = [ - program_course_enrollment.course_enrollment - for program_course_enrollment in program_course_enrollments - ] - self.assertTrue(all(course_enrollment.is_active for course_enrollment in course_enrollments)) - self.assertCountEqual( - course_keys, - [course_enrollment.course.id for course_enrollment in course_enrollments] - ) - - -class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase): - """ Tests for link_program_enrollments behavior """ - - def test_link_only_specified_program(self): - """ - Test that when there are two waiting program enrollments with the same external user key, - only the specified program's program enrollment will be linked - """ - program_enrollment = self._create_waiting_enrollment(self.program, '0001') - self._create_waiting_course_enrollment(program_enrollment, self.fruit_course) - self._create_waiting_course_enrollment(program_enrollment, self.animal_course) - - another_program_enrollment = self._create_waiting_enrollment(self.other_program, '0001') - self._create_waiting_course_enrollment(another_program_enrollment, self.fruit_course) - self._create_waiting_course_enrollment(another_program_enrollment, self.animal_course) - - self.call_command(self.program, ('0001', self.user_1.username)) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - self._assert_user_enrolled_in_program_courses(self.user_1, self.program, self.fruit_course, self.animal_course) - - self._assert_no_user(another_program_enrollment) - - def test_inactive_waiting_course_enrollment(self): - """ - Test that when a waiting program enrollment has waiting program course enrollments with a status of 'inactive' - the course enrollment created after calling link_program_enrollments will be inactive - """ - program_enrollment = self._create_waiting_enrollment(self.program, '0001') - active_enrollment = self._create_waiting_course_enrollment( - program_enrollment, - self.fruit_course - ) - inactive_enrollment = self._create_waiting_course_enrollment( - program_enrollment, - self.animal_course, - status='inactive' - ) - - self.call_command(self.program, ('0001', self.user_1.username)) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - - active_enrollment.refresh_from_db() - self.assertIsNotNone(active_enrollment.course_enrollment) - self.assertEqual(active_enrollment.course_enrollment.course.id, self.fruit_course) - self.assertTrue(active_enrollment.course_enrollment.is_active) - - inactive_enrollment.refresh_from_db() - self.assertIsNotNone(inactive_enrollment.course_enrollment) - self.assertEqual(inactive_enrollment.course_enrollment.course.id, self.animal_course) - self.assertFalse(inactive_enrollment.course_enrollment.is_active) - - -class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase): - """ Tests for link_program_enrollments error behavior """ +class TestLinkProgramEnrollmentManagementCommand(TestLinkProgramEnrollmentsMixin, TestCase): + """ Tests for exception behavior in the link_program_enrollments command """ def test_incorrectly_formatted_input(self): with self.assertRaisesRegex(CommandError, INCORRECT_PARAMETER_TPL.format('whoops')): - call_command(self.command, self.program, 'learner-01:user-01', 'whoops', 'learner-03:user-03') + call_command(Command(), self.program, 'learner-01:user-01', 'whoops', 'learner-03:user-03') def test_repeated_user_key(self): with self.assertRaisesRegex(CommandError, DUPLICATE_KEY_TPL.format('learner-01')): - self.call_command(self.program, ('learner-01', 'user-01'), ('learner-01', 'user-02')) - - def test_program_enrollment_not_found__nonexistant(self): - self._create_waiting_enrollment(self.program, '0001') - self._program_enrollment_not_found() - - def test_program_enrollment_not_found__different_program(self): - self._create_waiting_enrollment(self.program, '0001') - self._create_waiting_enrollment(self.other_program, '0002') - self._program_enrollment_not_found() - - def _program_enrollment_not_found(self): - """ - Helper for test_program_not_found_* tests. - tries to link user_1 to '0001' and user_2 to '0002' in program - asserts that user_2 was not linked because the enrollment was not found - """ - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0002', self.user_2.username)) - logger.check_present( - (COMMAND_PATH, 'WARNING', NO_PROGRAM_ENROLLMENT_TPL.format( - program_uuid=self.program, - external_student_key='0002' - )) - ) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - self._assert_no_program_enrollment(self.user_2, self.program) - - def test_user_not_found(self): - self._create_waiting_enrollment(self.program, '0001') - enrollment_2 = self._create_waiting_enrollment(self.program, '0002') - - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0002', 'nonexistant-user')) - logger.check_present( - (COMMAND_PATH, 'WARNING', NO_LMS_USER_TPL.format('nonexistant-user')) - ) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - self._assert_no_user(enrollment_2) - - def test_enrollment_already_linked_to_target_user(self): - self._create_waiting_enrollment(self.program, '0001') - program_enrollment = ProgramEnrollmentFactory.create( - user=self.user_2, - program_uuid=self.program, - external_user_key='0002', - ) - self._assert_no_program_enrollment(self.user_1, self.program, refresh=False) - self._assert_program_enrollment(self.user_2, self.program, '0002', refresh=False) - - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0002', self.user_2.username)) - logger.check_present( - (COMMAND_PATH, 'WARNING', get_existing_user_message(program_enrollment, self.user_2)) - ) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - self._assert_program_enrollment(self.user_2, self.program, '0002') - - def test_enrollment_already_linked_to_different_user(self): - self._create_waiting_enrollment(self.program, '0001') - enrollment = ProgramEnrollmentFactory.create( - program_uuid=self.program, - external_user_key='0003', - ) - user_3 = enrollment.user - - self._assert_no_program_enrollment(self.user_1, self.program, refresh=False) - self._assert_no_program_enrollment(self.user_2, self.program, refresh=False) - self._assert_program_enrollment(user_3, self.program, '0003', refresh=False) - - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0003', self.user_2.username)) - logger.check_present( - (COMMAND_PATH, 'WARNING', get_existing_user_message(enrollment, self.user_2)) - ) - - self._assert_program_enrollment(self.user_1, self.program, '0001') - self._assert_no_program_enrollment(self.user_2, self.program) - self._assert_program_enrollment(user_3, self.program, '0003') - - def test_error_enrolling_in_course(self): - nonexistant_course = CourseKey.from_string('course-v1:edX+Zilch+Bupkis') - - program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001') - course_enrollment_1 = self._create_waiting_course_enrollment(program_enrollment_1, nonexistant_course) - course_enrollment_2 = self._create_waiting_course_enrollment(program_enrollment_1, self.animal_course) - - program_enrollment_2 = self._create_waiting_enrollment(self.program, '0002') - self._create_waiting_course_enrollment(program_enrollment_2, self.fruit_course) - self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course) - - msg = COURSE_ENROLLMENT_ERR_TPL.format(user=self.user_1.username, course=nonexistant_course) - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0002', self.user_2.username)) - logger.check_present((COMMAND_PATH, 'ERROR', msg)) - - self._assert_no_program_enrollment(self.user_1, self.program) - self._assert_no_user(program_enrollment_1) - course_enrollment_1.refresh_from_db() - self.assertIsNone(course_enrollment_1.course_enrollment) - course_enrollment_2.refresh_from_db() - self.assertIsNone(course_enrollment_2.course_enrollment) - - self._assert_user_enrolled_in_program_courses(self.user_2, self.program, self.animal_course, self.fruit_course) - - def test_integrity_error(self): - existing_program_enrollment = self._create_waiting_enrollment(self.program, 'learner-0') - existing_program_enrollment.user = self.user_1 - existing_program_enrollment.save() - - program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001') - self._create_waiting_enrollment(self.program, '0002') - - msg = 'Integrity error while linking program enrollments' - with LogCapture() as logger: - self.call_command(self.program, ('0001', self.user_1.username), ('0002', self.user_2.username)) - logger.check_present((COMMAND_PATH, 'ERROR', msg)) - - self._assert_no_user(program_enrollment_1) - self._assert_program_enrollment(self.user_2, self.program, '0002') + call_command(Command(), self.program, 'learner-01:user-01', 'learner-01:user-02') diff --git a/lms/djangoapps/program_enrollments/tests/test_link_program_enrollments.py b/lms/djangoapps/program_enrollments/tests/test_link_program_enrollments.py new file mode 100644 index 0000000000..04297dc5ba --- /dev/null +++ b/lms/djangoapps/program_enrollments/tests/test_link_program_enrollments.py @@ -0,0 +1,370 @@ +""" +Tests for link_program_enrollments. +""" +from __future__ import absolute_import + +from uuid import uuid4 +from testfixtures import LogCapture + +from django.test import TestCase + +from edx_django_utils.cache import RequestCache +from lms.djangoapps.program_enrollments.link_program_enrollments import ( + link_program_enrollments_to_lms_users, + NO_PROGRAM_ENROLLMENT_TPL, + NO_LMS_USER_TPL, + COURSE_ENROLLMENT_ERR_TPL, + get_existing_user_message, +) +from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from student.tests.factories import UserFactory + +LOG_PATH = 'lms.djangoapps.program_enrollments.link_program_enrollments' + + +class TestLinkProgramEnrollmentsMixin(object): + """ Utility methods and test data for testing link_program_enrollments """ + + @classmethod + def setUpTestData(cls): # pylint: disable=missing-docstring + cls.program = uuid4() + cls.curriculum = uuid4() + cls.other_program = uuid4() + cls.fruit_course = CourseKey.from_string('course-v1:edX+Oranges+Apples') + cls.animal_course = CourseKey.from_string('course-v1:edX+Cats+Dogs') + CourseOverviewFactory.create(id=cls.fruit_course) + CourseOverviewFactory.create(id=cls.animal_course) + + def setUp(self): + self.user_1 = UserFactory.create() + self.user_2 = UserFactory.create() + + def tearDown(self): + RequestCache.clear_all_namespaces() + + def _create_waiting_enrollment(self, program_uuid, external_user_key): + """ + Create a waiting program enrollment for the given program and external user key. + """ + return ProgramEnrollmentFactory.create( + user=None, + program_uuid=program_uuid, + curriculum_uuid=self.curriculum, + external_user_key=external_user_key, + ) + + def _create_waiting_course_enrollment(self, program_enrollment, course_key, status='active'): + """ + Create a waiting program course enrollment for the given program enrollment, course key, and optionally status + """ + return ProgramCourseEnrollmentFactory.create( + program_enrollment=program_enrollment, + course_key=course_key, + course_enrollment=None, + status=status, + ) + + def _assert_no_user(self, program_enrollment, refresh=True): + """ + Assert that the given program enrollment has no LMS user associated with it + """ + if refresh: + program_enrollment.refresh_from_db() + self.assertIsNone(program_enrollment.user) + + def _assert_no_program_enrollment(self, user, program_uuid, refresh=True): + """ + Assert that the given user is not enrolled in the given program + """ + if refresh: + user.refresh_from_db() + self.assertFalse(user.programenrollment_set.filter(program_uuid=program_uuid).exists()) + + def _assert_program_enrollment(self, user, program_uuid, external_user_key, refresh=True): + """ + Assert that the given user is enrolled in the given program with the given external user key + """ + if refresh: + user.refresh_from_db() + enrollment = user.programenrollment_set.get(program_uuid=program_uuid, external_user_key=external_user_key) + self.assertIsNotNone(enrollment) + + def _assert_user_enrolled_in_program_courses(self, user, program_uuid, *course_keys): + """ + Assert that the given user is has active enrollments in the given courses through the given program + """ + user.refresh_from_db() + program_enrollment = user.programenrollment_set.get(user=user, program_uuid=program_uuid) + all_course_enrollments = program_enrollment.program_course_enrollments + program_course_enrollments = all_course_enrollments.select_related( + 'course_enrollment__course' + ).filter( + course_enrollment__isnull=False + ) + course_enrollments = [ + program_course_enrollment.course_enrollment + for program_course_enrollment in program_course_enrollments + ] + self.assertTrue(all(course_enrollment.is_active for course_enrollment in course_enrollments)) + self.assertCountEqual( + course_keys, + [course_enrollment.course.id for course_enrollment in course_enrollments] + ) + + def _assert_error_message(self, errors, error_key, logger, log_level, expected_error_msg): + logger.check_present((LOG_PATH, log_level, expected_error_msg)) + self.assertDictEqual( + { + error_key: expected_error_msg + }, + errors + ) + + +class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase): + """ Tests for link_program_enrollments behavior """ + + def test_link_only_specified_program(self): + """ + Test that when there are two waiting program enrollments with the same external user key, + only the specified program's program enrollment will be linked + """ + program_enrollment = self._create_waiting_enrollment(self.program, '0001') + self._create_waiting_course_enrollment(program_enrollment, self.fruit_course) + self._create_waiting_course_enrollment(program_enrollment, self.animal_course) + + another_program_enrollment = self._create_waiting_enrollment(self.other_program, '0001') + self._create_waiting_course_enrollment(another_program_enrollment, self.fruit_course) + self._create_waiting_course_enrollment(another_program_enrollment, self.animal_course) + + link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username}) + + self._assert_program_enrollment(self.user_1, self.program, '0001') + self._assert_user_enrolled_in_program_courses(self.user_1, self.program, self.fruit_course, self.animal_course) + + self._assert_no_user(another_program_enrollment) + + def test_inactive_waiting_course_enrollment(self): + """ + Test that when a waiting program enrollment has waiting program course enrollments with a status of 'inactive' + the course enrollment created after calling link_program_enrollments will be inactive + """ + program_enrollment = self._create_waiting_enrollment(self.program, '0001') + active_enrollment = self._create_waiting_course_enrollment( + program_enrollment, + self.fruit_course + ) + inactive_enrollment = self._create_waiting_course_enrollment( + program_enrollment, + self.animal_course, + status='inactive' + ) + + link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username}) + + self._assert_program_enrollment(self.user_1, self.program, '0001') + + active_enrollment.refresh_from_db() + self.assertIsNotNone(active_enrollment.course_enrollment) + self.assertEqual(active_enrollment.course_enrollment.course.id, self.fruit_course) + self.assertTrue(active_enrollment.course_enrollment.is_active) + + inactive_enrollment.refresh_from_db() + self.assertIsNotNone(inactive_enrollment.course_enrollment) + self.assertEqual(inactive_enrollment.course_enrollment.course.id, self.animal_course) + self.assertFalse(inactive_enrollment.course_enrollment.is_active) + + +class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase): + """ Tests for link_program_enrollments error behavior """ + + def test_program_enrollment_not_found__nonexistant(self): + self._create_waiting_enrollment(self.program, '0001') + self._program_enrollment_not_found() + + def test_program_enrollment_not_found__different_program(self): + self._create_waiting_enrollment(self.program, '0001') + self._create_waiting_enrollment(self.other_program, '0002') + self._program_enrollment_not_found() + + def _program_enrollment_not_found(self): + """ + Helper for test_program_not_found_* tests. + tries to link user_1 to '0001' and user_2 to '0002' in program + asserts that user_2 was not linked because the enrollment was not found + """ + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0002': self.user_2.username, + } + ) + expected_error_msg = NO_PROGRAM_ENROLLMENT_TPL.format( + program_uuid=self.program, + external_student_key='0002' + ) + logger.check_present((LOG_PATH, 'WARNING', expected_error_msg)) + + self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg}) + self._assert_program_enrollment(self.user_1, self.program, '0001') + self._assert_no_program_enrollment(self.user_2, self.program) + + def test_user_not_found(self): + self._create_waiting_enrollment(self.program, '0001') + enrollment_2 = self._create_waiting_enrollment(self.program, '0002') + + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0002': 'nonexistant-user', + } + ) + expected_error_msg = NO_LMS_USER_TPL.format('nonexistant-user') + logger.check_present((LOG_PATH, 'WARNING', expected_error_msg)) + + self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg}) + self._assert_program_enrollment(self.user_1, self.program, '0001') + self._assert_no_user(enrollment_2) + + def test_enrollment_already_linked_to_target_user(self): + self._create_waiting_enrollment(self.program, '0001') + program_enrollment = ProgramEnrollmentFactory.create( + user=self.user_2, + program_uuid=self.program, + external_user_key='0002', + ) + self._assert_no_program_enrollment(self.user_1, self.program, refresh=False) + self._assert_program_enrollment(self.user_2, self.program, '0002', refresh=False) + + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0002': self.user_2.username + } + ) + expected_error_msg = get_existing_user_message(program_enrollment, self.user_2) + logger.check_present((LOG_PATH, 'WARNING', expected_error_msg)) + + self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg}) + self._assert_program_enrollment(self.user_1, self.program, '0001') + self._assert_program_enrollment(self.user_2, self.program, '0002') + + def test_enrollment_already_linked_to_different_user(self): + self._create_waiting_enrollment(self.program, '0001') + enrollment = ProgramEnrollmentFactory.create( + program_uuid=self.program, + external_user_key='0003', + ) + user_3 = enrollment.user + + self._assert_no_program_enrollment(self.user_1, self.program, refresh=False) + self._assert_no_program_enrollment(self.user_2, self.program, refresh=False) + self._assert_program_enrollment(user_3, self.program, '0003', refresh=False) + + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0003': self.user_2.username, + } + ) + expected_error_msg = get_existing_user_message(enrollment, self.user_2) + logger.check_present((LOG_PATH, 'WARNING', expected_error_msg)) + + self.assertDictEqual(errors, {('0003', self.user_2.username): expected_error_msg}) + self._assert_program_enrollment(self.user_1, self.program, '0001') + self._assert_no_program_enrollment(self.user_2, self.program) + self._assert_program_enrollment(user_3, self.program, '0003') + + def test_error_enrolling_in_course(self): + nonexistant_course = CourseKey.from_string('course-v1:edX+Zilch+Bupkis') + + program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001') + course_enrollment_1 = self._create_waiting_course_enrollment(program_enrollment_1, nonexistant_course) + course_enrollment_2 = self._create_waiting_course_enrollment(program_enrollment_1, self.animal_course) + + program_enrollment_2 = self._create_waiting_enrollment(self.program, '0002') + self._create_waiting_course_enrollment(program_enrollment_2, self.fruit_course) + self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course) + + msg = COURSE_ENROLLMENT_ERR_TPL.format(user=self.user_1.username, course=nonexistant_course) + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0002': self.user_2.username + } + ) + logger.check_present((LOG_PATH, 'ERROR', msg)) + + self.assertDictEqual(errors, {('0001', self.user_1.username): 'NonExistentCourseError: ' + msg}) + self._assert_no_program_enrollment(self.user_1, self.program) + self._assert_no_user(program_enrollment_1) + course_enrollment_1.refresh_from_db() + self.assertIsNone(course_enrollment_1.course_enrollment) + course_enrollment_2.refresh_from_db() + self.assertIsNone(course_enrollment_2.course_enrollment) + + self._assert_user_enrolled_in_program_courses(self.user_2, self.program, self.animal_course, self.fruit_course) + + def test_integrity_error(self): + existing_program_enrollment = self._create_waiting_enrollment(self.program, 'learner-0') + existing_program_enrollment.user = self.user_1 + existing_program_enrollment.save() + + program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001') + self._create_waiting_enrollment(self.program, '0002') + + msg = 'Integrity error while linking program enrollments' + with LogCapture() as logger: + errors = link_program_enrollments_to_lms_users( + self.program, + { + '0001': self.user_1.username, + '0002': self.user_2.username, + } + ) + logger.check_present((LOG_PATH, 'ERROR', msg)) + + self.assertEqual(len(errors), 1) + self.assertIn('UNIQUE constraint failed', errors[('0001', self.user_1.username)]) + self._assert_no_user(program_enrollment_1) + self._assert_program_enrollment(self.user_2, self.program, '0002') + + def test_invalid_uuid(self): + self._create_waiting_enrollment(self.program, 'learner-0') + with self.assertRaisesMessage(ValueError, 'badly formed hexadecimal UUID string'): + link_program_enrollments_to_lms_users( + 'notauuid::thisisntauuid', + { + 'learner-0': self.user_1.username, + } + ) + + def test_None(self): + self._create_waiting_enrollment(self.program, 'learner-0') + msg = 'external_user_key or username cannot be None' + with self.assertRaisesMessage(ValueError, msg): + link_program_enrollments_to_lms_users( + self.program, + { + None: self.user_1.username, + } + ) + with self.assertRaisesMessage(ValueError, msg): + link_program_enrollments_to_lms_users( + 'notauuid::thisisntauuid', + { + 'learner-0': None, + } + ) diff --git a/lms/djangoapps/support/static/support/jsx/program_enrollments/index.jsx b/lms/djangoapps/support/static/support/jsx/program_enrollments/index.jsx new file mode 100644 index 0000000000..52decbe3aa --- /dev/null +++ b/lms/djangoapps/support/static/support/jsx/program_enrollments/index.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Cookies from 'js-cookie'; +import { Button, InputText, TextArea, StatusAlert } from '@edx/paragon'; + +export const LinkProgramEnrollmentsSupportPage = props => ( +
+ + {props.successes.length > 0 && ( + + There were { props.successes.length } successful linkages + + )} + /> + )} + {props.errors.map(errorItem => ( + + ))} + +