Skip to content
Snippets Groups Projects
Unverified Commit ecb65a4f authored by Jansen Kantor's avatar Jansen Kantor Committed by GitHub
Browse files

Jkantor/link program enrollments (#21330)

* add management command to link program enrollments to users
parent fff69a9f
No related branches found
Tags release-2021-07-16-15.03
No related merge requests found
""" Management command to link program enrollments and external student_keys to an LMS user """
import logging
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from student.models import CourseEnrollmentException
logger = logging.getLogger(__name__)
User = get_user_model()
INCORRECT_PARAMETER_TPL = u'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
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):
"""
Management command to manually link ProgramEnrollments without an LMS user to an LMS user by username
Usage:
./manage.py lms link_program_enrollments <program_uuid> <user_item>*
where a <user_item> is a string formatted as <external_user_key>:<lms_username>
Normally, program enrollments should be linked by the Django Social Auth post_save signal handler
`lms.djangoapps.program_enrollments.signals.matriculate_learner`, but in the case that a partner does not
have an IDP set up for learners to log in through, we need a way to link enrollments
Provided a program uuid and a list of external_user_key:lms_username, this command will look up the matching
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.
If an external user key is specified twice, an exception will be raised and no enrollments will be modified.
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 the input will be skipped. All other inputs will be processed and
enrollments updated.
If there is an error while enrolling a user in a waiting program course enrollment, the error will be
logged, but we will continue attempting to enroll the user in courses, and we will process all other
input users
"""
help = u'Manually links ProgramEnrollment records to LMS users'
def add_arguments(self, parser):
parser.add_argument(
'program_uuid',
help='the program in which we are linking enrollments to users',
)
parser.add_argument(
'user_items',
nargs='*',
help='specify the users to link, in the format <external_student_key>:<lms_username>*',
)
# 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
self.link_program_enrollment(program_enrollment, user)
def parse_user_items(self, user_items):
"""
Params:
list of strings in the format 'external_user_key:lms_username'
Returns:
dict mapping external user keys to lms usernames
"""
result = {}
for user_item in user_items:
split_args = user_item.split(':')
if len(split_args) != 2:
message = (INCORRECT_PARAMETER_TPL).format(user_item)
raise CommandError(message)
external_user_key = split_args[0]
lms_username = split_args[1]
if external_user_key in result:
raise CommandError(DUPLICATE_KEY_TPL.format(external_user_key))
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
"""
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()
for program_course_enrollment in program_enrollment.program_course_enrollments.all():
try:
program_course_enrollment.enroll(user)
except CourseEnrollmentException:
logger.warning(COURSE_ENROLLMENT_ERR_TPL.format(
user=user.username,
course=program_course_enrollment.course_key
))
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,
)
"""
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.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.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,
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 """
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')
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')
self._create_waiting_course_enrollment(program_enrollment_1, nonexistant_course)
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, nonexistant_course)
self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course)
msg_1 = COURSE_ENROLLMENT_ERR_TPL.format(user=self.user_1.username, course=nonexistant_course)
msg_2 = COURSE_ENROLLMENT_ERR_TPL.format(user=self.user_2.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, 'WARNING', msg_1))
logger.check_present((COMMAND_PATH, 'WARNING', msg_2))
self._assert_user_enrolled_in_program_courses(self.user_1, self.program, self.animal_course)
self._assert_user_enrolled_in_program_courses(self.user_2, self.program, self.animal_course)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment