UTAsutin is an example partner who would use mixed casing on their external_user_key references for program enrollment upload and matriculation. Update the system to be case insensitive on external_user_key Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
566 lines
21 KiB
Python
566 lines
21 KiB
Python
"""
|
|
Python API functions related to writing program enrollments.
|
|
|
|
Outside of this subpackage, import these functions
|
|
from `lms.djangoapps.program_enrollments.api`.
|
|
"""
|
|
|
|
|
|
import logging
|
|
|
|
from requests.structures import CaseInsensitiveDict
|
|
from simple_history.utils import bulk_create_with_history
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.models import CourseEnrollment, NonExistentCourseError
|
|
from common.djangoapps.student.roles import CourseStaffRole
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
|
|
from ..constants import ProgramCourseEnrollmentRoles, ProgramCourseEnrollmentStatuses
|
|
from ..constants import ProgramCourseOperationStatuses as ProgramCourseOpStatuses
|
|
from ..constants import ProgramEnrollmentStatuses
|
|
from ..constants import ProgramOperationStatuses as ProgramOpStatuses
|
|
from ..exceptions import ProviderDoesNotExistException
|
|
from ..models import CourseAccessRoleAssignment, ProgramCourseEnrollment, ProgramEnrollment
|
|
from .reading import fetch_program_course_enrollments_by_students, fetch_program_enrollments, get_users_by_external_keys
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def write_program_enrollments(program_uuid, enrollment_requests, create, update):
|
|
"""
|
|
Bulk create/update a set of program enrollments.
|
|
|
|
Arguments:
|
|
program_uuid (UUID|str)
|
|
enrollment_requests (list[dict]): dicts in the form:
|
|
* 'external_user_key': str
|
|
* 'status': str from ProgramEnrollmentStatuses
|
|
* 'curriculum_uuid': str, omittable if `create==False`.
|
|
create (bool): non-existent enrollments will be created iff `create`,
|
|
otherwise they will be skipped as 'duplicate'.
|
|
update (bool): existing enrollments will be updated iff `update`,
|
|
otherwise they will be skipped as 'not-in-program'
|
|
|
|
At least one of `create` or `update` must be True.
|
|
|
|
Returns: dict[str: str]
|
|
Mapping of external user keys to strings from ProgramOperationStatuses.
|
|
"""
|
|
if not (create or update):
|
|
raise ValueError("At least one of (create, update) must be True")
|
|
requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
|
|
external_keys = set(requests_by_key)
|
|
try:
|
|
users_by_key = get_users_by_external_keys(program_uuid, external_keys)
|
|
except ProviderDoesNotExistException:
|
|
# Organization has not yet set up their identity provider.
|
|
# Just act as if none of the external users have been registered.
|
|
users_by_key = {key: None for key in external_keys}
|
|
|
|
# Fetch existing program enrollments.
|
|
existing_enrollments = fetch_program_enrollments(
|
|
program_uuid=program_uuid, external_user_keys=external_keys
|
|
)
|
|
existing_enrollments_by_key = CaseInsensitiveDict({key: None for key in external_keys})
|
|
existing_enrollments_by_key.update({
|
|
enrollment.external_user_key: enrollment
|
|
for enrollment in existing_enrollments
|
|
})
|
|
|
|
# For each enrollment request, try to create/update:
|
|
# * For creates, build up list `to_save`, which we will bulk-create afterwards.
|
|
# * For updates, do them in place.
|
|
# Update `results` with the new status or an error status for each operation.
|
|
results = {}
|
|
to_save = []
|
|
for external_key, request in requests_by_key.items():
|
|
status = request['status']
|
|
if status not in ProgramEnrollmentStatuses.__ALL__:
|
|
results[external_key] = ProgramOpStatuses.INVALID_STATUS
|
|
continue
|
|
user = users_by_key[external_key]
|
|
existing_enrollment = existing_enrollments_by_key.get(external_key)
|
|
if existing_enrollment:
|
|
if not update:
|
|
results[external_key] = ProgramOpStatuses.CONFLICT
|
|
continue
|
|
results[external_key] = change_program_enrollment_status(
|
|
existing_enrollment, status
|
|
)
|
|
else:
|
|
if not create:
|
|
results[external_key] = ProgramOpStatuses.NOT_IN_PROGRAM
|
|
continue
|
|
new_enrollment = create_program_enrollment(
|
|
program_uuid=program_uuid,
|
|
curriculum_uuid=request['curriculum_uuid'],
|
|
user=user,
|
|
external_user_key=external_key,
|
|
status=status,
|
|
save=False,
|
|
)
|
|
to_save.append(new_enrollment)
|
|
results[external_key] = new_enrollment.status
|
|
|
|
# Bulk-create all new program enrollments and corresponding history records
|
|
# Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
|
|
# See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
|
|
if to_save:
|
|
bulk_create_with_history(to_save, ProgramEnrollment)
|
|
|
|
results.update({key: ProgramOpStatuses.DUPLICATED for key in duplicated_keys})
|
|
return results
|
|
|
|
|
|
def create_program_enrollment(
|
|
program_uuid,
|
|
curriculum_uuid,
|
|
user,
|
|
external_user_key,
|
|
status,
|
|
save=True,
|
|
):
|
|
"""
|
|
Create a program enrollment.
|
|
|
|
Arguments:
|
|
program_uuid (UUID|str)
|
|
curriculum_uuid (str)
|
|
user (User)
|
|
external_user_key (str)
|
|
status (str): from ProgramEnrollmentStatuses
|
|
save (bool): Whether to save the created ProgamEnrollment.
|
|
Defaults to True. One may set this to False in order to
|
|
bulk-create the enrollments.
|
|
|
|
Returns: ProgramEnrollment
|
|
"""
|
|
if not (user or external_user_key):
|
|
raise ValueError("At least one of (user, external_user_key) must be ")
|
|
program_enrollment = ProgramEnrollment(
|
|
program_uuid=program_uuid,
|
|
curriculum_uuid=curriculum_uuid,
|
|
user=user,
|
|
external_user_key=external_user_key,
|
|
status=status,
|
|
)
|
|
if save:
|
|
program_enrollment.save()
|
|
return program_enrollment
|
|
|
|
|
|
def change_program_enrollment_status(program_enrollment, new_status):
|
|
"""
|
|
Update a program enrollment with a new status.
|
|
|
|
Arguments:
|
|
program_enrollment (ProgramEnrollment)
|
|
status (str): from ProgramCourseEnrollmentStatuses
|
|
|
|
Returns: str
|
|
String from ProgramOperationStatuses.
|
|
"""
|
|
if new_status not in ProgramEnrollmentStatuses.__ALL__:
|
|
return ProgramOpStatuses.INVALID_STATUS
|
|
program_enrollment.status = new_status
|
|
program_enrollment.save()
|
|
return program_enrollment.status
|
|
|
|
|
|
def write_program_course_enrollments(
|
|
program_uuid,
|
|
course_key,
|
|
enrollment_requests,
|
|
create,
|
|
update,
|
|
):
|
|
"""
|
|
Bulk create/update a set of program-course enrollments.
|
|
|
|
Arguments:
|
|
program_uuid (UUID|str)
|
|
enrollment_requests (list[dict]): dicts in the form:
|
|
* 'external_user_key': str
|
|
* 'status': str from ProgramCourseEnrollmentStatuses
|
|
* 'course_staff': Boolean if the user should have the CourseStaff role
|
|
create (bool): non-existent enrollments will be created iff `create`,
|
|
otherwise they will be skipped as 'duplicate'.
|
|
update (bool): existing enrollments will be updated iff `update`,
|
|
otherwise they will be skipped as 'not-in-program'
|
|
|
|
At least one of `create` or `update` must be True.
|
|
|
|
Returns: dict[str: str]
|
|
Mapping of external user keys to strings from ProgramCourseOperationStatuses.
|
|
"""
|
|
if not (create or update):
|
|
raise ValueError("At least one of (create, update) must be True")
|
|
requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
|
|
processable_external_keys = set(requests_by_key)
|
|
|
|
results = {}
|
|
results.update({
|
|
key: ProgramCourseOpStatuses.DUPLICATED for key in duplicated_keys
|
|
})
|
|
|
|
if not processable_external_keys:
|
|
return results
|
|
|
|
program_enrollments = fetch_program_enrollments(
|
|
program_uuid=program_uuid,
|
|
external_user_keys=processable_external_keys,
|
|
).prefetch_related('program_course_enrollments')
|
|
program_enrollments_by_key = CaseInsensitiveDict({
|
|
enrollment.external_user_key: enrollment for enrollment in program_enrollments
|
|
})
|
|
|
|
# Fetch enrollments regardless of anchored Program Enrollments
|
|
existing_course_enrollments = fetch_program_course_enrollments_by_students(
|
|
external_user_keys=processable_external_keys,
|
|
course_keys=[course_key],
|
|
).select_related('program_enrollment')
|
|
|
|
conflicting_user_key_and_status = _get_conflicting_active_course_enrollments(
|
|
requests_by_key,
|
|
existing_course_enrollments,
|
|
program_uuid,
|
|
course_key
|
|
)
|
|
|
|
# Remove the conflicted items from the requests dictionary,
|
|
# so we can continue our processing below
|
|
for conflicting_user_key in conflicting_user_key_and_status:
|
|
del requests_by_key[conflicting_user_key]
|
|
|
|
results.update(conflicting_user_key_and_status)
|
|
|
|
# Now, limit the course enrollments to the same program uuid
|
|
existing_course_enrollments_of_program_enrollment = existing_course_enrollments.filter(
|
|
program_enrollment__program_uuid=program_uuid
|
|
)
|
|
|
|
existing_course_enrollments_by_key = CaseInsensitiveDict({
|
|
key: None for key in processable_external_keys
|
|
})
|
|
existing_course_enrollments_by_key.update({
|
|
enrollment.program_enrollment.external_user_key: enrollment
|
|
for enrollment in existing_course_enrollments_of_program_enrollment
|
|
})
|
|
|
|
# For each enrollment request, try to create/update.
|
|
# For creates, build up list `to_save`, which we will bulk-create afterwards.
|
|
# For updates, do them in place in order to preserve history records.
|
|
# For each operation, update `results` with the new status or an error status.
|
|
enrollments_to_save = []
|
|
created_enrollments = []
|
|
updated_enrollments = []
|
|
staff_assignments_by_user_key = CaseInsensitiveDict()
|
|
for external_key, request in requests_by_key.items():
|
|
course_staff = request['course_staff']
|
|
status = request['status']
|
|
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
|
|
results[external_key] = ProgramCourseOpStatuses.INVALID_STATUS
|
|
continue
|
|
program_enrollment = program_enrollments_by_key.get(external_key)
|
|
if not program_enrollment:
|
|
results[external_key] = ProgramCourseOpStatuses.NOT_IN_PROGRAM
|
|
continue
|
|
existing_course_enrollment = existing_course_enrollments_by_key[external_key]
|
|
if existing_course_enrollment:
|
|
if not update:
|
|
results[external_key] = ProgramCourseOpStatuses.CONFLICT
|
|
continue
|
|
results[external_key] = change_program_course_enrollment_status(
|
|
existing_course_enrollment, status
|
|
)
|
|
updated_enrollments.append(existing_course_enrollment)
|
|
else:
|
|
if not create:
|
|
results[external_key] = ProgramCourseOpStatuses.NOT_FOUND
|
|
continue
|
|
new_course_enrollment = create_program_course_enrollment(
|
|
program_enrollment, course_key, status, save=False
|
|
)
|
|
enrollments_to_save.append(new_course_enrollment)
|
|
results[external_key] = new_course_enrollment.status
|
|
|
|
if course_staff is not None:
|
|
staff_assignments_by_user_key[external_key] = course_staff
|
|
|
|
# Bulk-create all new program-course enrollments and corresponding history records.
|
|
# Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
|
|
# See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
|
|
if enrollments_to_save:
|
|
created_enrollments = bulk_create_with_history(enrollments_to_save, ProgramCourseEnrollment)
|
|
|
|
# For every created/updated enrollment, check if the user should be course staff.
|
|
# If that enrollment has a linked user, assign the user the course staff role
|
|
# If that enrollment does not have a linked user, create a CourseAccessRoleAssignment
|
|
# for that enrollment.
|
|
written_enrollments = ProgramCourseEnrollment.objects.filter(
|
|
id__in=[enrollment.id for enrollment in created_enrollments + updated_enrollments]
|
|
).select_related('program_enrollment')
|
|
|
|
_assign_course_staff_role(course_key, written_enrollments, staff_assignments_by_user_key)
|
|
|
|
return results
|
|
|
|
|
|
def create_program_course_enrollment(program_enrollment, course_key, status, save=True):
|
|
"""
|
|
Create a program course enrollment.
|
|
|
|
If `program_enrollment` is realized (i.e., has a non-null User),
|
|
then also create a course enrollment.
|
|
|
|
Arguments:
|
|
program_enrollment (ProgramEnrollment)
|
|
course_key (CourseKey|str)
|
|
status (str): from ProgramCourseEnrollmentStatuses
|
|
save (bool): Whether to save the created ProgamCourseEnrollment.
|
|
Defaults to True. One may set this to False in order to
|
|
bulk-create the enrollments.
|
|
Note that if a CourseEnrollment is created, it will be saved
|
|
regardless of this value.
|
|
|
|
Returns: ProgramCourseEnrollment
|
|
|
|
Raises: NonExistentCourseError
|
|
"""
|
|
_ensure_course_exists(course_key, program_enrollment.external_user_key)
|
|
course_enrollment = (
|
|
enroll_in_masters_track(program_enrollment.user, course_key, status)
|
|
if program_enrollment.user
|
|
else None
|
|
)
|
|
program_course_enrollment = ProgramCourseEnrollment(
|
|
program_enrollment=program_enrollment,
|
|
course_key=course_key,
|
|
course_enrollment=course_enrollment,
|
|
status=status,
|
|
)
|
|
if save:
|
|
program_course_enrollment.save()
|
|
return program_course_enrollment
|
|
|
|
|
|
def change_program_course_enrollment_status(program_course_enrollment, new_status):
|
|
"""
|
|
Update a program course enrollment with a new status.
|
|
|
|
If `program_course_enrollment` is realized with a CourseEnrollment,
|
|
then also update that.
|
|
|
|
Arguments:
|
|
program_course_enrollment (ProgramCourseEnrollment)
|
|
status (str): from ProgramCourseEnrollmentStatuses
|
|
|
|
Returns: str
|
|
String from ProgramOperationCourseStatuses.
|
|
"""
|
|
if new_status == program_course_enrollment.status:
|
|
return new_status
|
|
if new_status == ProgramCourseEnrollmentStatuses.ACTIVE:
|
|
active = True
|
|
elif new_status == ProgramCourseEnrollmentStatuses.INACTIVE:
|
|
active = False
|
|
else:
|
|
return ProgramCourseOpStatuses.INVALID_STATUS
|
|
if program_course_enrollment.course_enrollment:
|
|
if active:
|
|
program_course_enrollment.course_enrollment.activate()
|
|
else:
|
|
program_course_enrollment.course_enrollment.deactivate()
|
|
program_course_enrollment.status = new_status
|
|
program_course_enrollment.save()
|
|
return program_course_enrollment.status
|
|
|
|
|
|
def enroll_in_masters_track(user, course_key, status):
|
|
"""
|
|
Ensure that the user is enrolled in the Master's track of course.
|
|
Either creates or updates a course enrollment.
|
|
|
|
Arguments:
|
|
user (User)
|
|
course_key (CourseKey|str)
|
|
status (str): from ProgramCourseEnrollmentStatuses
|
|
|
|
Returns: CourseEnrollment
|
|
|
|
Raises: NonExistentCourseError
|
|
"""
|
|
_ensure_course_exists(course_key, user.id)
|
|
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
|
|
raise ValueError(f"invalid ProgramCourseEnrollmentStatus: {status}")
|
|
if CourseEnrollment.is_enrolled(user, course_key):
|
|
course_enrollment = CourseEnrollment.objects.get(
|
|
user=user,
|
|
course_id=course_key,
|
|
)
|
|
if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
|
|
course_enrollment.mode = CourseMode.MASTERS
|
|
course_enrollment.save()
|
|
message_template = (
|
|
"Converted course enrollment for user id={} "
|
|
"and course key={} from mode {} to Master's."
|
|
)
|
|
logger.info(
|
|
message_template.format(user.id, course_key, course_enrollment.mode)
|
|
)
|
|
elif course_enrollment.mode != CourseMode.MASTERS:
|
|
error_message = (
|
|
"Cannot convert CourseEnrollment to Master's from mode {}. "
|
|
"user id={}, course_key={}."
|
|
).format(
|
|
course_enrollment.mode, user.id, course_key
|
|
)
|
|
logger.error(error_message)
|
|
else:
|
|
course_enrollment = CourseEnrollment.enroll(
|
|
user,
|
|
course_key,
|
|
mode=CourseMode.MASTERS,
|
|
check_access=False,
|
|
)
|
|
if course_enrollment.mode == CourseMode.MASTERS:
|
|
if status == ProgramCourseEnrollmentStatuses.INACTIVE:
|
|
course_enrollment.deactivate()
|
|
return course_enrollment
|
|
|
|
|
|
def _assign_course_staff_role(course_key, enrollments, staff_assignments):
|
|
"""
|
|
Grant or remove the course staff role for a set of enrollments on a course.
|
|
For enrollment without a linked user, a CourseAccessRoleAssignment will be
|
|
created (or removed) for that enrollment.
|
|
|
|
Arguments:
|
|
enrollments (list): ProgramCourseEnrollments to update
|
|
staff_assignments (dict): Maps an enrollment's external key to a course staff value
|
|
"""
|
|
enrollment_role_assignments_to_delete = []
|
|
for enrollment in enrollments:
|
|
if enrollment.course_key != course_key:
|
|
continue
|
|
|
|
external_key = enrollment.program_enrollment.external_user_key
|
|
user = enrollment.program_enrollment.user
|
|
course_staff = staff_assignments.get(external_key)
|
|
|
|
if user:
|
|
if course_staff is True:
|
|
CourseStaffRole(course_key).add_users(user)
|
|
elif course_staff is False:
|
|
CourseStaffRole(course_key).remove_users(user)
|
|
else:
|
|
if course_staff is True:
|
|
CourseAccessRoleAssignment.objects.update_or_create(
|
|
enrollment=enrollment,
|
|
role=ProgramCourseEnrollmentRoles.COURSE_STAFF
|
|
)
|
|
elif course_staff is False:
|
|
enrollment_role_assignments_to_delete.append(enrollment)
|
|
|
|
if enrollment_role_assignments_to_delete:
|
|
CourseAccessRoleAssignment.objects.filter(
|
|
enrollment__in=enrollment_role_assignments_to_delete
|
|
).delete()
|
|
|
|
|
|
def _ensure_course_exists(course_key, user_key_or_id):
|
|
"""
|
|
Log and raise an error if `course_key` does not refer to a real course run.
|
|
|
|
`user_key_or_id` should be a non-PII value identifying the user that
|
|
can be used in the log message.
|
|
"""
|
|
if CourseOverview.course_exists(course_key):
|
|
return
|
|
logger.error(
|
|
"Cannot enroll user={} in non-existent course={}".format(
|
|
user_key_or_id,
|
|
course_key,
|
|
)
|
|
)
|
|
raise NonExistentCourseError
|
|
|
|
|
|
def _organize_requests_by_external_key(enrollment_requests):
|
|
"""
|
|
Get dict of enrollment requests by external key.
|
|
External keys associated with more than one request are split out into a set,
|
|
and their enrollment requests thrown away.
|
|
|
|
Arguments:
|
|
enrollment_requests (list[dict])
|
|
|
|
Returns:
|
|
(requests_by_key, duplicated_keys)
|
|
where requests_by_key is dict[str: dict]
|
|
and duplicated_keys is set[str].
|
|
"""
|
|
requests_by_key = CaseInsensitiveDict()
|
|
duplicated_keys = set()
|
|
for request in enrollment_requests:
|
|
key = request['external_user_key']
|
|
if key in duplicated_keys:
|
|
continue
|
|
if key in requests_by_key:
|
|
duplicated_keys.add(key)
|
|
del requests_by_key[key]
|
|
continue
|
|
requests_by_key[key] = request
|
|
return requests_by_key, duplicated_keys
|
|
|
|
|
|
def _get_conflicting_active_course_enrollments(
|
|
requests_by_key,
|
|
existing_course_enrollments,
|
|
program_uuid,
|
|
course_key
|
|
):
|
|
"""
|
|
Process the list of existing course enrollments together with
|
|
the enrollment request list stored in 'requests_by_key'. Detect
|
|
whether we have conflicting ACTIVE ProgramCourseEnrollment entries.
|
|
When detected, log about it and return the conflicting entry with
|
|
duplicated status.
|
|
|
|
Arguments:
|
|
requests_by_key (dict)
|
|
existing_course_enrollments (queryset[ProgramCourseEnrollment]),
|
|
program_uuid (UUID|str),
|
|
course_key (str)
|
|
|
|
Returns:
|
|
results (dict) with detected conflict entry, or empty dict.
|
|
"""
|
|
conflicted_by_user_key = CaseInsensitiveDict()
|
|
|
|
requested_statuses_by_user_key = CaseInsensitiveDict({
|
|
key: request.get('status') for key, request in requests_by_key.items()
|
|
})
|
|
|
|
for existing_enrollment in existing_course_enrollments:
|
|
external_user_key = existing_enrollment.program_enrollment.external_user_key
|
|
requested_status = requested_statuses_by_user_key.get(
|
|
existing_enrollment.program_enrollment.external_user_key
|
|
)
|
|
if (
|
|
requested_status
|
|
and requested_status == ProgramCourseEnrollmentStatuses.ACTIVE
|
|
and existing_enrollment.is_active
|
|
and str(existing_enrollment.program_enrollment.program_uuid) != str(program_uuid)
|
|
):
|
|
logger.error(
|
|
'Detected conflicting active ProgramCourseEnrollment. This is happening on'
|
|
' The program_uuid [{}] with course_key [{}] for external_user_key [{}]'.format(
|
|
program_uuid,
|
|
course_key,
|
|
external_user_key
|
|
))
|
|
conflicted_by_user_key[external_user_key] = ProgramCourseOpStatuses.CONFLICT
|
|
return conflicted_by_user_key
|