Revert "Revert "Create Python API for program_enrollments: Part IV"" (#21759)
This reverts commit a67b9f70a16a0f16a842aad84754b245a2480b5f, reinstating commit cf78660ed35712f9bb7c112f70411179070d7382. The original commit was reverted because I thought I found bugs in it while verifying it on Stage, but it turns out that it was simply misconfigured Stage data that causing errors. The original commit's message has has been copied below: This commit completes the program_enrollments LMS app Python API for the time being. It does the following: * Add bulk-lookup of users by external key in api/reading.py * Add bulk-writing of program enrollments in api/writing.py * Move grade-reading to api/grades.py * Refactor api/linking.py to use api/writing.py * Refactor signals.py to use api/linking.py * Update rest_api/v1/views.py to utilize all these changes * Update linking management command and support tool to use API * Remove outdated tests from test_models.py * Misc. cleanup EDUCATOR-4321
This commit is contained in:
@@ -1,11 +1,39 @@
|
||||
"""
|
||||
Python API exposed by the proram_enrollments app to other in-process apps.
|
||||
Python API exposed by the program_enrollments app to other in-process apps.
|
||||
|
||||
The functions are split into separate files for code organization, but they
|
||||
are wildcard-imported into here so they can be imported directly from
|
||||
are imported into here so they can be imported directly from
|
||||
`lms.djangoapps.program_enrollments.api`.
|
||||
|
||||
When adding new functions to this API, add them to the appropriate module
|
||||
within the /api/ folder, and then "expose" them here by importing them.
|
||||
|
||||
We use explicit imports here because (1) it hides internal variables in the
|
||||
sub-modules and (2) it provides a nice catalog of functions for someone
|
||||
using this API.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from .linking import * # pylint: disable=wildcard-import
|
||||
from .reading import * # pylint: disable=wildcard-import
|
||||
from .grades import iter_program_course_grades
|
||||
from .linking import link_program_enrollment_to_lms_user, link_program_enrollments
|
||||
from .reading import (
|
||||
fetch_program_course_enrollments,
|
||||
fetch_program_course_enrollments_by_student,
|
||||
fetch_program_enrollments,
|
||||
fetch_program_enrollments_by_student,
|
||||
get_program_course_enrollment,
|
||||
get_program_enrollment,
|
||||
get_provider_slug,
|
||||
get_saml_provider_for_organization,
|
||||
get_saml_provider_for_program,
|
||||
get_users_by_external_keys
|
||||
)
|
||||
from .writing import (
|
||||
change_program_course_enrollment_status,
|
||||
change_program_enrollment_status,
|
||||
create_program_course_enrollment,
|
||||
create_program_enrollment,
|
||||
enroll_in_masters_track,
|
||||
write_program_course_enrollments,
|
||||
write_program_enrollments
|
||||
)
|
||||
|
||||
@@ -10,9 +10,9 @@ away in https://openedx.atlassian.net/browse/ENT-2294
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
|
||||
from lms.djangoapps.course_api.api import get_course_run_url as get_course_run_url_util
|
||||
from lms.djangoapps.course_api.api import get_due_dates as get_due_dates_util
|
||||
from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
|
||||
|
||||
|
||||
def get_due_dates(request, course_key, user):
|
||||
|
||||
135
lms/djangoapps/program_enrollments/api/grades.py
Normal file
135
lms/djangoapps/program_enrollments/api/grades.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Python API functions related to reading program-course grades.
|
||||
|
||||
Outside of this subpackage, import these functions
|
||||
from `lms.djangoapps.program_enrollments.api`.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from six import text_type
|
||||
|
||||
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
|
||||
from util.query import read_replica_or_default
|
||||
|
||||
from .reading import fetch_program_course_enrollments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def iter_program_course_grades(program_uuid, course_key, paginate_queryset_fn=None):
|
||||
"""
|
||||
Load grades (or grading errors) for a given program-course.
|
||||
|
||||
Arguments:
|
||||
program_uuid (str)
|
||||
course_key (CourseKey)
|
||||
paginate_queryset_fn (QuerySet -> QuerySet):
|
||||
Optional function to paginate the results,
|
||||
generally passed in from `self.request.paginate_queryset`
|
||||
on a paginated DRF `APIView`.
|
||||
If `None`, all results will be loaded and returned.
|
||||
|
||||
Returns: generator[BaseProgramCourseGrade]
|
||||
"""
|
||||
enrollments_qs = fetch_program_course_enrollments(
|
||||
program_uuid=program_uuid,
|
||||
course_key=course_key,
|
||||
realized_only=True,
|
||||
).select_related(
|
||||
'program_enrollment',
|
||||
'program_enrollment__user',
|
||||
).using(read_replica_or_default())
|
||||
enrollments = (
|
||||
paginate_queryset_fn(enrollments_qs) if paginate_queryset_fn
|
||||
else enrollments_qs
|
||||
)
|
||||
if not enrollments:
|
||||
return []
|
||||
return _generate_grades(course_key, list(enrollments))
|
||||
|
||||
|
||||
def _generate_grades(course_key, enrollments):
|
||||
"""
|
||||
Load enrolled user grades for a program-course,
|
||||
using bulk fetching for efficiency.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseKey)
|
||||
enrollments (list[ProgramCourseEnrollment])
|
||||
|
||||
Yields: BaseProgramCourseGrade
|
||||
"""
|
||||
users = [enrollment.program_enrollment.user for enrollment in enrollments]
|
||||
prefetch_course_grades(course_key, users)
|
||||
try:
|
||||
grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
|
||||
for enrollment, grade_tuple in zip(enrollments, grades_iter):
|
||||
user, course_grade, exception = grade_tuple
|
||||
if course_grade:
|
||||
yield ProgramCourseGradeOk(enrollment, course_grade)
|
||||
else:
|
||||
error_template = 'Failed to load course grade for user ID {} in {}: {}'
|
||||
error_string = error_template.format(
|
||||
user.id,
|
||||
course_key,
|
||||
text_type(exception) if exception else 'Unknown error'
|
||||
)
|
||||
logger.error(error_string)
|
||||
yield ProgramCourseGradeError(enrollment, exception)
|
||||
finally:
|
||||
clear_prefetched_course_grades(course_key)
|
||||
|
||||
|
||||
class BaseProgramCourseGrade(object):
|
||||
"""
|
||||
Base for either a courserun grade or grade-loading failure.
|
||||
|
||||
Can be passed to ProgramCourseGradeResultSerializer.
|
||||
"""
|
||||
is_error = None # Override in subclass
|
||||
|
||||
def __init__(self, program_course_enrollment):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment,
|
||||
create a BaseProgramCourseGrade instance.
|
||||
"""
|
||||
self.program_course_enrollment = program_course_enrollment
|
||||
|
||||
|
||||
class ProgramCourseGradeOk(BaseProgramCourseGrade):
|
||||
"""
|
||||
Represents a courserun grade for a user enrolled through a program.
|
||||
"""
|
||||
is_error = False
|
||||
|
||||
def __init__(self, program_course_enrollment, course_grade):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment and course grade object,
|
||||
create a ProgramCourseGradeOk.
|
||||
"""
|
||||
super(ProgramCourseGradeOk, self).__init__(
|
||||
program_course_enrollment
|
||||
)
|
||||
self.passed = course_grade.passed
|
||||
self.percent = course_grade.percent
|
||||
self.letter_grade = course_grade.letter_grade
|
||||
|
||||
|
||||
class ProgramCourseGradeError(BaseProgramCourseGrade):
|
||||
"""
|
||||
Represents a failure to load a courserun grade for a user enrolled through
|
||||
a program.
|
||||
"""
|
||||
is_error = True
|
||||
|
||||
def __init__(self, program_course_enrollment, exception=None):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment and an Exception,
|
||||
create a ProgramCourseGradeError.
|
||||
"""
|
||||
super(ProgramCourseGradeError, self).__init__(
|
||||
program_course_enrollment
|
||||
)
|
||||
self.error = text_type(exception) if exception else "Unknown error"
|
||||
@@ -8,7 +8,6 @@ from `lms.djangoapps.program_enrollments.api`.
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
@@ -16,6 +15,7 @@ from django.db import IntegrityError, transaction
|
||||
from student.models import CourseEnrollmentException
|
||||
|
||||
from .reading import fetch_program_enrollments
|
||||
from .writing import enroll_in_masters_track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@@ -26,9 +26,6 @@ NO_PROGRAM_ENROLLMENT_TEMPLATE = (
|
||||
'key={external_student_key}'
|
||||
)
|
||||
NO_LMS_USER_TEMPLATE = 'No user found with username {}'
|
||||
COURSE_ENROLLMENT_ERR_TEMPLATE = (
|
||||
'Failed to enroll user {user} with waiting program course enrollment for course {course}'
|
||||
)
|
||||
EXISTING_USER_TEMPLATE = (
|
||||
'Program enrollment with external_student_key={external_student_key} is already linked to '
|
||||
'{account_relation} account username={username}'
|
||||
@@ -36,7 +33,7 @@ EXISTING_USER_TEMPLATE = (
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames):
|
||||
def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
"""
|
||||
Utility function to link ProgramEnrollments to LMS Users
|
||||
|
||||
@@ -44,10 +41,9 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
|
||||
-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
|
||||
}
|
||||
Returns: dict[str: str]
|
||||
Map from external keys to errors, for the external keys of users whose
|
||||
linking produced errors.
|
||||
|
||||
Raises: ValueError if None is included in external_keys_to_usernames
|
||||
|
||||
@@ -60,7 +56,7 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
|
||||
- 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
|
||||
external_key. 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
|
||||
@@ -70,34 +66,29 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
|
||||
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_by_ext_key(
|
||||
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
|
||||
users_by_username = _get_lms_users(external_keys_to_usernames.values())
|
||||
for external_student_key, username in external_keys_to_usernames.items():
|
||||
program_enrollment = program_enrollments.get(external_student_key)
|
||||
user = users_by_username.get(username)
|
||||
if not user:
|
||||
error_message = NO_LMS_USER_TEMPLATE.format(username)
|
||||
|
||||
program_enrollment = program_enrollments.get(external_student_key)
|
||||
if not program_enrollment:
|
||||
elif not program_enrollment:
|
||||
error_message = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(
|
||||
program_uuid=program_uuid,
|
||||
external_student_key=external_student_key
|
||||
)
|
||||
elif program_enrollment.user:
|
||||
error_message = user_already_linked_message(program_enrollment, user)
|
||||
|
||||
error_message = _user_already_linked_message(program_enrollment, user)
|
||||
else:
|
||||
error_message = None
|
||||
if error_message:
|
||||
logger.warning(error_message)
|
||||
errors[item] = error_message
|
||||
errors[external_student_key] = error_message
|
||||
continue
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
link_program_enrollment_to_lms_user(program_enrollment, user)
|
||||
@@ -110,28 +101,11 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
|
||||
if str(e):
|
||||
error_message += ': '
|
||||
error_message += str(e)
|
||||
errors[item] = error_message
|
||||
errors[external_student_key] = error_message
|
||||
return errors
|
||||
|
||||
|
||||
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 user_already_linked_message(program_enrollment, user):
|
||||
def _user_already_linked_message(program_enrollment, user):
|
||||
"""
|
||||
Creates an error message that the specified program enrollment is already linked to an lms user
|
||||
"""
|
||||
@@ -144,12 +118,6 @@ def user_already_linked_message(program_enrollment, user):
|
||||
)
|
||||
|
||||
|
||||
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_by_ext_key(program_uuid, external_student_keys):
|
||||
"""
|
||||
Does a bulk read of ProgramEnrollments for a given program and list of external student keys
|
||||
@@ -177,35 +145,37 @@ def _get_lms_users(lms_usernames):
|
||||
}
|
||||
|
||||
|
||||
def _link_program_enrollment(program_enrollment, user):
|
||||
def link_program_enrollment_to_lms_user(program_enrollment, user):
|
||||
"""
|
||||
Links program enrollment to 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 IntegrityError if ProgramEnrollment is invalid
|
||||
Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
|
||||
program course enrollment
|
||||
IntegrityError if we try to create invalid records.
|
||||
"""
|
||||
logger.info('Linking external student key {} and user {}'.format(
|
||||
link_log_info = 'user id={} with external_user_key={} for program uuid={}'.format(
|
||||
user.id,
|
||||
program_enrollment.external_user_key,
|
||||
user.username
|
||||
))
|
||||
program_enrollment.program_uuid,
|
||||
)
|
||||
logger.info("Linking " + link_log_info)
|
||||
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)
|
||||
program_enrollment.save()
|
||||
program_course_enrollments = program_enrollment.program_course_enrollments.all()
|
||||
for pce in program_course_enrollments:
|
||||
pce.course_enrollment = enroll_in_masters_track(
|
||||
user, pce.course_key, pce.status
|
||||
)
|
||||
pce.save()
|
||||
except IntegrityError:
|
||||
logger.error("Integrity error while linking " + link_log_info)
|
||||
raise
|
||||
except CourseEnrollmentException as e:
|
||||
error_message = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
|
||||
user=user.username,
|
||||
course=program_course_enrollment.course_key
|
||||
logger.error(
|
||||
"CourseEnrollmentException while linking {}: {}".format(
|
||||
link_log_info, str(e)
|
||||
)
|
||||
)
|
||||
logger.exception(error_message)
|
||||
raise type(e)(error_message)
|
||||
raise
|
||||
|
||||
@@ -6,6 +6,19 @@ from `lms.djangoapps.program_enrollments.api`.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from organizations.models import Organization
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from third_party_auth.models import SAMLProviderConfig
|
||||
|
||||
from ..exceptions import (
|
||||
BadOrganizationShortNameException,
|
||||
ProgramDoesNotExistException,
|
||||
ProgramHasNoAuthoringOrganizationException,
|
||||
ProviderConfigurationException,
|
||||
ProviderDoesNotExistException
|
||||
)
|
||||
from ..models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
|
||||
_STUDENT_ARG_ERROR_MESSAGE = (
|
||||
@@ -144,6 +157,7 @@ def fetch_program_course_enrollments(
|
||||
users=None,
|
||||
external_user_keys=None,
|
||||
program_enrollment_statuses=None,
|
||||
program_enrollments=None,
|
||||
active_only=False,
|
||||
inactive_only=False,
|
||||
realized_only=False,
|
||||
@@ -161,6 +175,7 @@ def fetch_program_course_enrollments(
|
||||
* users (iterable[User])
|
||||
* external_user_keys (iterable[str])
|
||||
* program_enrollment_statuses (iterable[str])
|
||||
* program_enrollments (iterable[ProgramEnrollment])
|
||||
* active_only (bool)
|
||||
* inactive_only (bool)
|
||||
* realized_only (bool)
|
||||
@@ -185,6 +200,7 @@ def fetch_program_course_enrollments(
|
||||
"program_enrollment__user__in": users,
|
||||
"program_enrollment__external_user_key__in": external_user_keys,
|
||||
"program_enrollment__status__in": program_enrollment_statuses,
|
||||
"program_enrollment__in": program_enrollments,
|
||||
}
|
||||
if active_only:
|
||||
filters["status"] = "active"
|
||||
@@ -319,3 +335,106 @@ def _remove_none_values(dictionary):
|
||||
return {
|
||||
key: value for key, value in dictionary.items() if value is not None
|
||||
}
|
||||
|
||||
|
||||
def get_users_by_external_keys(program_uuid, external_user_keys):
|
||||
"""
|
||||
Given a program and a set of external keys,
|
||||
return a dict from external user keys to Users.
|
||||
|
||||
Args:
|
||||
program_uuid (UUID|str):
|
||||
uuid for program these users is/will be enrolled in
|
||||
external_user_keys (sequence[str]):
|
||||
external user keys used by the program creator's IdP.
|
||||
|
||||
Returns: dict[str: User|None]
|
||||
A dict mapping external user keys to Users.
|
||||
If an external user key is not registered, then None is returned instead
|
||||
of a User for that key.
|
||||
|
||||
Raises:
|
||||
ProgramDoesNotExistException
|
||||
ProgramHasNoAuthoringOrganizationException
|
||||
BadOrganizationShortNameException
|
||||
ProviderDoesNotExistsException
|
||||
ProviderConfigurationException
|
||||
"""
|
||||
saml_provider = get_saml_provider_for_program(program_uuid)
|
||||
social_auth_uids = {
|
||||
saml_provider.get_social_auth_uid(external_user_key)
|
||||
for external_user_key in external_user_keys
|
||||
}
|
||||
social_auths = UserSocialAuth.objects.filter(uid__in=social_auth_uids)
|
||||
found_users_by_external_keys = {
|
||||
saml_provider.get_remote_id_from_social_auth(social_auth): social_auth.user
|
||||
for social_auth in social_auths
|
||||
}
|
||||
# Default all external keys to None, because external keys
|
||||
# without a User will not appear in `found_users_by_external_keys`.
|
||||
users_by_external_keys = {key: None for key in external_user_keys}
|
||||
users_by_external_keys.update(found_users_by_external_keys)
|
||||
return users_by_external_keys
|
||||
|
||||
|
||||
def get_saml_provider_for_program(program_uuid):
|
||||
"""
|
||||
Return currently configured SAML provider for the Organization
|
||||
administering the given program.
|
||||
|
||||
Arguments:
|
||||
program_uuid (UUID|str)
|
||||
|
||||
Returns: SAMLProvider
|
||||
|
||||
Raises:
|
||||
ProgramDoesNotExistException
|
||||
ProgramHasNoAuthoringOrganizationException
|
||||
BadOrganizationShortNameException
|
||||
"""
|
||||
program = get_programs(uuid=program_uuid)
|
||||
if program is None:
|
||||
raise ProgramDoesNotExistException(program_uuid)
|
||||
authoring_orgs = program.get('authoring_organizations')
|
||||
org_key = authoring_orgs[0].get('key') if authoring_orgs else None
|
||||
if not org_key:
|
||||
raise ProgramHasNoAuthoringOrganizationException(program_uuid)
|
||||
try:
|
||||
organization = Organization.objects.get(short_name=org_key)
|
||||
except Organization.DoesNotExist:
|
||||
raise BadOrganizationShortNameException(org_key)
|
||||
return get_saml_provider_for_organization(organization)
|
||||
|
||||
|
||||
def get_saml_provider_for_organization(organization):
|
||||
"""
|
||||
Return currently configured SAML provider for the given Organization.
|
||||
|
||||
Arguments:
|
||||
organization: Organization
|
||||
|
||||
Returns: SAMLProvider
|
||||
|
||||
Raises:
|
||||
ProviderDoesNotExistsException
|
||||
ProviderConfigurationException
|
||||
"""
|
||||
try:
|
||||
provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
|
||||
except SAMLProviderConfig.DoesNotExist:
|
||||
raise ProviderDoesNotExistException(organization)
|
||||
except SAMLProviderConfig.MultipleObjectsReturned:
|
||||
raise ProviderConfigurationException(organization)
|
||||
return provider_config
|
||||
|
||||
|
||||
def get_provider_slug(provider_config):
|
||||
"""
|
||||
Returns slug identifying a SAML provider.
|
||||
|
||||
Arguments:
|
||||
provider_config: SAMLProvider
|
||||
|
||||
Returns: str
|
||||
"""
|
||||
return provider_config.provider_id.strip('saml-')
|
||||
|
||||
12
lms/djangoapps/program_enrollments/api/tests/test_grades.py
Normal file
12
lms/djangoapps/program_enrollments/api/tests/test_grades.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
(Future home of) Tests for program_enrollments grade-reading Python API.
|
||||
|
||||
Currently, we do not directly unit test `load_program_course_grades`.
|
||||
This is okay for now because it is used in
|
||||
`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
|
||||
Eventually it would be good to directly test the Python API function and just use
|
||||
mocks in the view tests.
|
||||
This file serves as a placeholder and reminder to do that the next time there
|
||||
is development on the program_enrollments grades API.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
@@ -15,11 +15,10 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from ..linking import (
|
||||
COURSE_ENROLLMENT_ERR_TEMPLATE,
|
||||
NO_LMS_USER_TEMPLATE,
|
||||
NO_PROGRAM_ENROLLMENT_TEMPLATE,
|
||||
link_program_enrollments_to_lms_users,
|
||||
user_already_linked_message
|
||||
_user_already_linked_message,
|
||||
link_program_enrollments
|
||||
)
|
||||
|
||||
LOG_PATH = 'lms.djangoapps.program_enrollments.api.linking'
|
||||
@@ -123,15 +122,6 @@ class TestLinkProgramEnrollmentsMixin(object):
|
||||
[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 linking behavior """
|
||||
@@ -149,7 +139,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
|
||||
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})
|
||||
link_program_enrollments(self.program, {'0001': self.user_1.username})
|
||||
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_user_enrolled_in_program_courses(
|
||||
@@ -175,7 +165,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
|
||||
status='inactive'
|
||||
)
|
||||
|
||||
link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
|
||||
link_program_enrollments(self.program, {'0001': self.user_1.username})
|
||||
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
|
||||
@@ -209,7 +199,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
asserts that user_2 was not linked because the enrollment was not found
|
||||
"""
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
@@ -222,7 +212,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
)
|
||||
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
|
||||
|
||||
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
|
||||
self.assertDictEqual(errors, {'0002': expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_no_program_enrollment(self.user_2, self.program)
|
||||
|
||||
@@ -231,7 +221,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
enrollment_2 = self._create_waiting_enrollment(self.program, '0002')
|
||||
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
@@ -241,7 +231,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
expected_error_msg = NO_LMS_USER_TEMPLATE.format('nonexistant-user')
|
||||
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
|
||||
|
||||
self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg})
|
||||
self.assertDictEqual(errors, {'0002': expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_no_user(enrollment_2)
|
||||
|
||||
@@ -256,17 +246,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
self._assert_program_enrollment(self.user_2, self.program, '0002', refresh=False)
|
||||
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username
|
||||
}
|
||||
)
|
||||
expected_error_msg = user_already_linked_message(program_enrollment, self.user_2)
|
||||
expected_error_msg = _user_already_linked_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.assertDictEqual(errors, {'0002': expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_program_enrollment(self.user_2, self.program, '0002')
|
||||
|
||||
@@ -283,17 +273,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
self._assert_program_enrollment(user_3, self.program, '0003', refresh=False)
|
||||
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0003': self.user_2.username,
|
||||
}
|
||||
)
|
||||
expected_error_msg = user_already_linked_message(enrollment, self.user_2)
|
||||
expected_error_msg = _user_already_linked_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.assertDictEqual(errors, {'0003': 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')
|
||||
@@ -313,22 +303,14 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
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_TEMPLATE.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}
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username
|
||||
}
|
||||
)
|
||||
self.assertIn(errors['0001'], 'NonExistentCourseError: ')
|
||||
self._assert_no_program_enrollment(self.user_1, self.program)
|
||||
self._assert_no_user(program_enrollment_1)
|
||||
course_enrollment_1.refresh_from_db()
|
||||
@@ -348,46 +330,15 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
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))
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn('UNIQUE constraint failed', errors[('0001', self.user_1.username)])
|
||||
self.assertIn('UNIQUE constraint failed', errors['0001'])
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for account linking Python API.
|
||||
Tests for program enrollment reading Python API.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
@@ -7,16 +7,30 @@ from uuid import UUID
|
||||
|
||||
import ddt
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from organizations.tests.factories import OrganizationFactory
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses as PCEStatuses
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses as PEStatuses
|
||||
from lms.djangoapps.program_enrollments.exceptions import (
|
||||
OrganizationDoesNotExistException,
|
||||
ProgramDoesNotExistException,
|
||||
ProviderConfigurationException,
|
||||
ProviderDoesNotExistException
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
|
||||
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
|
||||
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from third_party_auth.tests.factories import SAMLProviderConfigFactory
|
||||
|
||||
from ..reading import (
|
||||
fetch_program_course_enrollments,
|
||||
@@ -24,7 +38,8 @@ from ..reading import (
|
||||
fetch_program_enrollments,
|
||||
fetch_program_enrollments_by_student,
|
||||
get_program_course_enrollment,
|
||||
get_program_enrollment
|
||||
get_program_enrollment,
|
||||
get_users_by_external_keys
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -425,3 +440,136 @@ class ProgramEnrollmentReadingTests(TestCase):
|
||||
)
|
||||
del result['usernames']
|
||||
return result
|
||||
|
||||
|
||||
class GetUsersByExternalKeysTests(CacheIsolationTestCase):
|
||||
"""
|
||||
Tests for the get_users_by_external_keys function
|
||||
"""
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super(GetUsersByExternalKeysTests, cls).setUpTestData()
|
||||
cls.program_uuid = UUID('e7a82f8d-d485-486b-b733-a28222af92bf')
|
||||
cls.organization_key = 'ufo'
|
||||
cls.external_user_id = '1234'
|
||||
cls.user_0 = UserFactory(username='user-0')
|
||||
cls.user_1 = UserFactory(username='user-1')
|
||||
cls.user_2 = UserFactory(username='user-2')
|
||||
|
||||
def setUp(self):
|
||||
super(GetUsersByExternalKeysTests, self).setUp()
|
||||
catalog_org = CatalogOrganizationFactory.create(key=self.organization_key)
|
||||
program = ProgramFactory.create(
|
||||
uuid=self.program_uuid,
|
||||
authoring_organizations=[catalog_org]
|
||||
)
|
||||
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
|
||||
|
||||
def create_social_auth_entry(self, user, provider, external_id):
|
||||
"""
|
||||
helper functio to create a user social auth entry
|
||||
"""
|
||||
UserSocialAuth.objects.create(
|
||||
user=user,
|
||||
uid='{0}:{1}'.format(provider.slug, external_id),
|
||||
provider=provider.backend_name,
|
||||
)
|
||||
|
||||
def test_happy_path(self):
|
||||
"""
|
||||
Test that get_users_by_external_keys returns the expected
|
||||
mapping of external keys to users.
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
provider = SAMLProviderConfigFactory.create(organization=organization)
|
||||
self.create_social_auth_entry(self.user_0, provider, 'ext-user-0')
|
||||
self.create_social_auth_entry(self.user_1, provider, 'ext-user-1')
|
||||
self.create_social_auth_entry(self.user_2, provider, 'ext-user-2')
|
||||
requested_keys = {'ext-user-1', 'ext-user-2', 'ext-user-3'}
|
||||
actual = get_users_by_external_keys(self.program_uuid, requested_keys)
|
||||
# ext-user-0 not requested, ext-user-3 doesn't exist
|
||||
expected = {
|
||||
'ext-user-1': self.user_1,
|
||||
'ext-user-2': self.user_2,
|
||||
'ext-user-3': None,
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
def test_empty_request(self):
|
||||
"""
|
||||
Test that requesting no external keys does not cause an exception.
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
SAMLProviderConfigFactory.create(organization=organization)
|
||||
actual = get_users_by_external_keys(self.program_uuid, set())
|
||||
assert actual == {}
|
||||
|
||||
def test_catalog_program_does_not_exist(self):
|
||||
"""
|
||||
Test ProgramDoesNotExistException is thrown if the program cache does
|
||||
not include the requested program uuid.
|
||||
"""
|
||||
fake_program_uuid = UUID('80cc59e5-003e-4664-a582-48da44bc7e12')
|
||||
with self.assertRaises(ProgramDoesNotExistException):
|
||||
get_users_by_external_keys(fake_program_uuid, [])
|
||||
|
||||
def test_catalog_program_missing_org(self):
|
||||
"""
|
||||
Test OrganizationDoesNotExistException is thrown if the cached program does not
|
||||
have an authoring organization.
|
||||
"""
|
||||
program = ProgramFactory.create(
|
||||
uuid=self.program_uuid,
|
||||
authoring_organizations=[]
|
||||
)
|
||||
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
|
||||
with self.assertRaises(OrganizationDoesNotExistException):
|
||||
get_users_by_external_keys(self.program_uuid, [])
|
||||
|
||||
def test_lms_organization_not_found(self):
|
||||
"""
|
||||
Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
|
||||
matching the catalog program's authoring_organization
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name='some_other_org')
|
||||
SAMLProviderConfigFactory.create(organization=organization)
|
||||
with self.assertRaises(OrganizationDoesNotExistException):
|
||||
get_users_by_external_keys(self.program_uuid, [])
|
||||
|
||||
def test_saml_provider_not_found(self):
|
||||
"""
|
||||
Test that Prov exception is thrown if no SAML provider exists for this
|
||||
program's organization.
|
||||
"""
|
||||
OrganizationFactory.create(short_name=self.organization_key)
|
||||
with self.assertRaises(ProviderDoesNotExistException):
|
||||
get_users_by_external_keys(self.program_uuid, [])
|
||||
|
||||
def test_extra_saml_provider_disabled(self):
|
||||
"""
|
||||
If multiple samlprovider records exist with the same organization,
|
||||
but the extra record is disabled, no exception is raised.
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
SAMLProviderConfigFactory.create(organization=organization)
|
||||
# create a second active config for the same organization, NOT enabled
|
||||
SAMLProviderConfigFactory.create(
|
||||
organization=organization, slug='foox', enabled=False
|
||||
)
|
||||
get_users_by_external_keys(self.program_uuid, [])
|
||||
|
||||
def test_extra_saml_provider_enabled(self):
|
||||
"""
|
||||
If multiple enabled samlprovider records exist with the same organization
|
||||
an exception is raised.
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
SAMLProviderConfigFactory.create(organization=organization)
|
||||
# create a second active config for the same organizationm, IS enabled
|
||||
SAMLProviderConfigFactory.create(
|
||||
organization=organization, slug='foox', enabled=True
|
||||
)
|
||||
with self.assertRaises(ProviderConfigurationException):
|
||||
get_users_by_external_keys(self.program_uuid, [])
|
||||
|
||||
12
lms/djangoapps/program_enrollments/api/tests/test_writing.py
Normal file
12
lms/djangoapps/program_enrollments/api/tests/test_writing.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
(Future home of) Tests for program enrollment writing Python API.
|
||||
|
||||
Currently, we do not directly unit test the functions in api/writing.py.
|
||||
This is okay for now because they are all used in
|
||||
`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
|
||||
Eventually it would be good to directly test the Python API function and just use
|
||||
mocks in the view tests.
|
||||
This file serves as a placeholder and reminder to do that the next time there
|
||||
is development on the program_enrollments writing API.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
426
lms/djangoapps/program_enrollments/api/writing.py
Normal file
426
lms/djangoapps/program_enrollments/api/writing.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Python API functions related to writing program enrollments.
|
||||
|
||||
Outside of this subpackage, import these functions
|
||||
from `lms.djangoapps.program_enrollments.api`.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment, NonExistentCourseError
|
||||
|
||||
from ..constants import ProgramCourseEnrollmentStatuses
|
||||
from ..constants import ProgramCourseOperationStatuses as ProgramCourseOpStatuses
|
||||
from ..constants import ProgramEnrollmentStatuses
|
||||
from ..constants import ProgramOperationStatuses as ProgramOpStatuses
|
||||
from ..exceptions import ProviderDoesNotExistException
|
||||
from ..models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from .reading import fetch_program_course_enrollments, 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 = {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.
|
||||
# (TODO: Django 2.2 will add bulk-update support, which we could use here)
|
||||
# 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.
|
||||
# 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:
|
||||
ProgramEnrollment.objects.bulk_create(to_save)
|
||||
|
||||
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
|
||||
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)
|
||||
external_keys = set(requests_by_key)
|
||||
program_enrollments = fetch_program_enrollments(
|
||||
program_uuid=program_uuid,
|
||||
external_user_keys=external_keys,
|
||||
).prefetch_related('program_course_enrollments')
|
||||
program_enrollments_by_key = {
|
||||
enrollment.external_user_key: enrollment for enrollment in program_enrollments
|
||||
}
|
||||
|
||||
# Fetch existing program-course enrollments.
|
||||
existing_course_enrollments = fetch_program_course_enrollments(
|
||||
program_uuid, course_key, program_enrollments=program_enrollments,
|
||||
)
|
||||
existing_course_enrollments_by_key = {key: None for key in external_keys}
|
||||
existing_course_enrollments_by_key.update({
|
||||
enrollment.program_enrollment.external_user_key: enrollment
|
||||
for enrollment in existing_course_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 (Django 2.2 will add bulk-update support).
|
||||
# For each operation, update `results` with the new status or an error status.
|
||||
results = {}
|
||||
to_save = []
|
||||
for external_key, request in requests_by_key.items():
|
||||
status = request['status']
|
||||
program_enrollment = program_enrollments_by_key.get(external_key)
|
||||
if not program_enrollment:
|
||||
results[external_key] = ProgramCourseOpStatuses.NOT_IN_PROGRAM
|
||||
continue
|
||||
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
|
||||
results[external_key] = ProgramCourseOpStatuses.INVALID_STATUS
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
to_save.append(new_course_enrollment)
|
||||
results[external_key] = new_course_enrollment.status
|
||||
|
||||
# Bulk-create all new program-course enrollments.
|
||||
# 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:
|
||||
ProgramCourseEnrollment.objects.bulk_create(to_save)
|
||||
|
||||
results.update({
|
||||
key: ProgramCourseOpStatuses.DUPLICATED for key in duplicated_keys
|
||||
})
|
||||
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 ProgramCourseEnrollmenStatuses
|
||||
|
||||
Returns: CourseEnrollment
|
||||
|
||||
Raises: NonExistentCourseError
|
||||
"""
|
||||
_ensure_course_exists(course_key, user.id)
|
||||
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
|
||||
raise ValueError("invalid ProgramCourseEnrollmenStatus: {}".format(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 _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 = {}
|
||||
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
|
||||
@@ -40,3 +40,76 @@ class ProgramCourseEnrollmentStatuses(object):
|
||||
__MODEL_CHOICES__ = (
|
||||
(status, status) for status in __ALL__
|
||||
)
|
||||
|
||||
|
||||
class _EnrollmentErrorStatuses(object):
|
||||
"""
|
||||
Error statuses common to program and program-course enrollments responses.
|
||||
"""
|
||||
|
||||
# Same student key supplied more than once.
|
||||
DUPLICATED = 'duplicated'
|
||||
|
||||
# Requested target status is invalid
|
||||
INVALID_STATUS = "invalid-status"
|
||||
|
||||
# In the case of a POST request, the enrollment already exists.
|
||||
CONFLICT = "conflict"
|
||||
|
||||
# Although the request is syntactically valid,
|
||||
# the change being made is not supported.
|
||||
# For example, it may be illegal to change a user's status back to A
|
||||
# after changing it to B, where A and B are two hypothetical enrollment
|
||||
# statuses.
|
||||
ILLEGAL_OPERATION = "illegal-operation"
|
||||
|
||||
# Could not modify program enrollment or create program-course
|
||||
# enrollment because the student is not enrolled in the program in the
|
||||
# first place.
|
||||
NOT_IN_PROGRAM = "not-in-program"
|
||||
|
||||
# Something unexpected went wrong.
|
||||
# If API users are seeing this, we need to investigate.
|
||||
INTERNAL_ERROR = "internal-error"
|
||||
|
||||
__ALL__ = (
|
||||
DUPLICATED,
|
||||
INVALID_STATUS,
|
||||
CONFLICT,
|
||||
ILLEGAL_OPERATION,
|
||||
NOT_IN_PROGRAM,
|
||||
INTERNAL_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class ProgramOperationStatuses(
|
||||
ProgramEnrollmentStatuses,
|
||||
_EnrollmentErrorStatuses,
|
||||
):
|
||||
"""
|
||||
Valid program enrollment operation statuses.
|
||||
|
||||
Combines error statuses and OK statuses.
|
||||
"""
|
||||
__OK__ = ProgramEnrollmentStatuses.__ALL__
|
||||
__ERRORS__ = _EnrollmentErrorStatuses.__ALL__
|
||||
__ALL__ = __OK__ + __ERRORS__
|
||||
|
||||
|
||||
class ProgramCourseOperationStatuses(
|
||||
ProgramCourseEnrollmentStatuses,
|
||||
_EnrollmentErrorStatuses,
|
||||
):
|
||||
"""
|
||||
Valid program-course enrollment operation statuses.
|
||||
|
||||
Combines error statuses and OK statuses.
|
||||
"""
|
||||
|
||||
# Could not modify program-course enrollment because the user
|
||||
# is not enrolled in the course in the first place.
|
||||
NOT_FOUND = "not-found"
|
||||
|
||||
__OK__ = ProgramCourseEnrollmentStatuses.__ALL__
|
||||
__ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
|
||||
__ALL__ = __OK__ + __ERRORS__
|
||||
|
||||
65
lms/djangoapps/program_enrollments/exceptions.py
Normal file
65
lms/djangoapps/program_enrollments/exceptions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Exceptions raised by functions exposed by program_enrollments Django app.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
# Every `__init__` here calls empty Exception() constructor.
|
||||
# pylint: disable=super-init-not-called
|
||||
|
||||
|
||||
class ProgramDoesNotExistException(Exception):
|
||||
|
||||
def __init__(self, program_uuid):
|
||||
self.program_uuid = program_uuid
|
||||
|
||||
def __str__(self):
|
||||
return 'Unable to find catalog program matching uuid {}'.format(self.program_uuid)
|
||||
|
||||
|
||||
class OrganizationDoesNotExistException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProgramHasNoAuthoringOrganizationException(OrganizationDoesNotExistException):
|
||||
|
||||
def __init__(self, program_uuid):
|
||||
self.program_uuid = program_uuid
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
'Cannot determine authoring organization key for catalog program {}'
|
||||
).format(self.program_uuid)
|
||||
|
||||
|
||||
class BadOrganizationShortNameException(OrganizationDoesNotExistException):
|
||||
|
||||
def __init__(self, organization_short_name):
|
||||
self.organization_short_name = organization_short_name
|
||||
|
||||
def __str__(self):
|
||||
return 'Unable to find organization for short_name {}'.format(
|
||||
self.organization_short_name
|
||||
)
|
||||
|
||||
|
||||
class ProviderDoesNotExistException(Exception):
|
||||
|
||||
def __init__(self, organization):
|
||||
self.organization = organization
|
||||
|
||||
def __str__(self):
|
||||
return 'Unable to find organization for short_name {}'.format(
|
||||
self.organization.id
|
||||
)
|
||||
|
||||
|
||||
class ProviderConfigurationException(Exception):
|
||||
|
||||
def __init__(self, organization):
|
||||
self.organization = organization
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
'Multiple active SAML configurations found for organization={}. '
|
||||
'Expected one.'
|
||||
).format(self.organization.short_name)
|
||||
@@ -1,18 +1,18 @@
|
||||
""" Management command to link program enrollments and external student_keys to an LMS user """
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
|
||||
from lms.djangoapps.program_enrollments.api import link_program_enrollments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
INCORRECT_PARAMETER_TEMPLATE = (
|
||||
'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
|
||||
"incorrectly formatted argument '{}', "
|
||||
"must be in form <external user key>:<lms username>"
|
||||
)
|
||||
DUPLICATE_KEY_TEMPLATE = 'external user key {} provided multiple times'
|
||||
|
||||
@@ -70,11 +70,17 @@ class Command(BaseCommand):
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def handle(self, program_uuid, user_items, *args, **options):
|
||||
try:
|
||||
parsed_program_uuid = UUID(program_uuid)
|
||||
except ValueError:
|
||||
raise CommandError("supplied program_uuid '{}' is not a valid UUID")
|
||||
ext_keys_to_usernames = self.parse_user_items(user_items)
|
||||
try:
|
||||
link_program_enrollments_to_lms_users(program_uuid, ext_keys_to_usernames)
|
||||
link_program_enrollments(
|
||||
parsed_program_uuid, ext_keys_to_usernames
|
||||
)
|
||||
except Exception as e:
|
||||
raise CommandError(e)
|
||||
raise CommandError(str(e))
|
||||
|
||||
def parse_user_items(self, user_items):
|
||||
"""
|
||||
@@ -82,18 +88,21 @@ class Command(BaseCommand):
|
||||
list of strings in the format 'external_user_key:lms_username'
|
||||
Returns:
|
||||
dict mapping external user keys to lms usernames
|
||||
Raises:
|
||||
CommandError
|
||||
"""
|
||||
result = {}
|
||||
for user_item in user_items:
|
||||
split_args = user_item.split(':')
|
||||
if len(split_args) != 2:
|
||||
message = (INCORRECT_PARAMETER_TEMPLATE).format(user_item)
|
||||
message = INCORRECT_PARAMETER_TEMPLATE.format(user_item)
|
||||
raise CommandError(message)
|
||||
external_user_key = split_args[0].strip()
|
||||
lms_username = split_args[1].strip()
|
||||
if not (external_user_key and lms_username):
|
||||
message = INCORRECT_PARAMETER_TEMPLATE.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_TEMPLATE.format(external_user_key))
|
||||
|
||||
result[external_user_key] = lms_username
|
||||
return result
|
||||
|
||||
@@ -3,6 +3,8 @@ Tests for the link_program_enrollments management command.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import mock
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
@@ -15,13 +17,13 @@ _COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_pro
|
||||
|
||||
class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
"""
|
||||
Test that the command calls link_program_enrollments_to_lms_users
|
||||
Test that the command calls link_program_enrollments
|
||||
correctly and handles exceptional input correctly.
|
||||
"""
|
||||
|
||||
program_uuid = 'a32c5da8-fb89-4f1e-97a7-b13de9e6dfa2'
|
||||
|
||||
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments_to_lms_users"
|
||||
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments"
|
||||
|
||||
@mock.patch(_LINKING_FUNCTION_MOCK_PATH, autospec=True)
|
||||
def test_good_input_calls_linking(self, mock_link):
|
||||
@@ -29,7 +31,7 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
Command(), self.program_uuid, 'learner-01:user-01', 'learner-02:user-02'
|
||||
)
|
||||
mock_link.assert_called_once_with(
|
||||
self.program_uuid,
|
||||
UUID(self.program_uuid),
|
||||
{
|
||||
'learner-01': 'user-01',
|
||||
'learner-02': 'user-02',
|
||||
@@ -45,6 +47,24 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
Command(), self.program_uuid, 'learner-01:user-01', 'whoops', 'learner-03:user-03'
|
||||
)
|
||||
|
||||
def test_missing_external_user_key(self):
|
||||
with self.assertRaisesRegex(
|
||||
CommandError,
|
||||
INCORRECT_PARAMETER_TEMPLATE.format('whoops: ')
|
||||
):
|
||||
call_command(
|
||||
Command(), self.program_uuid, 'learner-01:user-01', 'whoops: ', 'learner-03:user-03'
|
||||
)
|
||||
|
||||
def test_missing_username(self):
|
||||
with self.assertRaisesRegex(
|
||||
CommandError,
|
||||
INCORRECT_PARAMETER_TEMPLATE.format(' :whoops')
|
||||
):
|
||||
call_command(
|
||||
Command(), self.program_uuid, 'learner-01:user-01', ' :whoops', 'learner-03:user-03'
|
||||
)
|
||||
|
||||
def test_repeated_user_key_exception(self):
|
||||
with self.assertRaisesRegex(
|
||||
CommandError,
|
||||
@@ -53,3 +73,10 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
call_command(
|
||||
Command(), self.program_uuid, 'learner-01:user-01', 'learner-01:user-02'
|
||||
)
|
||||
|
||||
def test_invalid_uuid(self):
|
||||
error_regex = r"supplied program_uuid '.*' is not a valid UUID"
|
||||
with self.assertRaisesRegex(CommandError, error_regex):
|
||||
call_command(
|
||||
Command(), 'notauuid::thisisntauuid', 'learner-0:user-01'
|
||||
)
|
||||
|
||||
@@ -4,8 +4,6 @@ Django model specifications for the Program Enrollments API
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
@@ -13,16 +11,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from model_utils.models import TimeStampedModel
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from simple_history.models import HistoricalRecords
|
||||
from six import text_type
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment, NonExistentCourseError
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from .constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unicode
|
||||
"""
|
||||
@@ -82,17 +75,6 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
|
||||
enrollments.update(external_user_key=None)
|
||||
return True
|
||||
|
||||
def get_program_course_enrollment(self, course_key):
|
||||
"""
|
||||
Returns the ProgramCourseEnrollment associated with this ProgramEnrollment and given course,
|
||||
None if it does not exist
|
||||
"""
|
||||
try:
|
||||
program_course_enrollment = self.program_course_enrollments.get(course_key=course_key)
|
||||
except ProgramCourseEnrollment.DoesNotExist:
|
||||
return None
|
||||
return program_course_enrollment
|
||||
|
||||
def __str__(self):
|
||||
return '[ProgramEnrollment id={}]'.format(self.id)
|
||||
|
||||
@@ -136,89 +118,3 @@ class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missin
|
||||
|
||||
def __str__(self):
|
||||
return '[ProgramCourseEnrollment id={}]'.format(self.id)
|
||||
|
||||
@classmethod
|
||||
def create_program_course_enrollment(cls, program_enrollment, course_key, status):
|
||||
"""
|
||||
Create ProgramCourseEnrollment for the given course and program enrollment
|
||||
"""
|
||||
program_course_enrollment = ProgramCourseEnrollment.objects.create(
|
||||
program_enrollment=program_enrollment,
|
||||
course_key=course_key,
|
||||
status=status,
|
||||
)
|
||||
|
||||
if program_enrollment.user:
|
||||
program_course_enrollment.enroll(program_enrollment.user)
|
||||
|
||||
return program_course_enrollment.status
|
||||
|
||||
def change_status(self, status):
|
||||
"""
|
||||
Modify ProgramCourseEnrollment status and course_enrollment status if it exists
|
||||
"""
|
||||
if status == self.status:
|
||||
return status
|
||||
|
||||
self.status = status
|
||||
if self.course_enrollment:
|
||||
if status == ProgramCourseEnrollmentStatuses.ACTIVE:
|
||||
self.course_enrollment.activate()
|
||||
elif status == ProgramCourseEnrollmentStatuses.INACTIVE:
|
||||
self.course_enrollment.deactivate()
|
||||
else:
|
||||
message = ("Changed {enrollment} status to {status}, not changing course_enrollment"
|
||||
" status because status is not '{active}' or '{inactive}'")
|
||||
logger.warn(message.format(
|
||||
enrollment=self,
|
||||
status=status,
|
||||
active=ProgramCourseEnrollmentStatuses.ACTIVE,
|
||||
inactive=ProgramCourseEnrollmentStatuses.INACTIVE
|
||||
))
|
||||
elif self.program_enrollment.user:
|
||||
logger.warn("User {user} {program_enrollment} {course_key} has no course_enrollment".format(
|
||||
user=self.program_enrollment.user,
|
||||
program_enrollment=self.program_enrollment,
|
||||
course_key=self.course_key,
|
||||
))
|
||||
self.save()
|
||||
return self.status
|
||||
|
||||
def enroll(self, user):
|
||||
"""
|
||||
Create a CourseEnrollment to enroll user in course
|
||||
"""
|
||||
try:
|
||||
CourseOverview.get_from_id(self.course_key)
|
||||
except CourseOverview.DoesNotExist:
|
||||
logger.warning(
|
||||
"User %s failed to enroll in non-existent course %s", user.id,
|
||||
text_type(self.course_key),
|
||||
)
|
||||
raise NonExistentCourseError
|
||||
|
||||
if CourseEnrollment.is_enrolled(user, self.course_key):
|
||||
course_enrollment = CourseEnrollment.objects.get(
|
||||
user=user,
|
||||
course_id=self.course_key,
|
||||
)
|
||||
if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
|
||||
course_enrollment.mode = CourseMode.MASTERS
|
||||
course_enrollment.save()
|
||||
self.course_enrollment = course_enrollment
|
||||
message_template = (
|
||||
"Attempted to create course enrollment for user={user} "
|
||||
"and course={course} but an enrollment already exists. "
|
||||
"Existing enrollment will be used instead."
|
||||
)
|
||||
logger.info(message_template.format(user=user.id, course=self.course_key))
|
||||
else:
|
||||
self.course_enrollment = CourseEnrollment.enroll(
|
||||
user,
|
||||
self.course_key,
|
||||
mode=CourseMode.MASTERS,
|
||||
check_access=False,
|
||||
)
|
||||
if self.status == ProgramCourseEnrollmentStatuses.INACTIVE:
|
||||
self.course_enrollment.deactivate()
|
||||
self.save()
|
||||
|
||||
@@ -3,8 +3,6 @@ Constants used throughout the program_enrollments V1 API.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
|
||||
|
||||
# Captures strings composed of alphanumeric characters a-f and dashes.
|
||||
PROGRAM_UUID_PATTERN = r'(?P<program_uuid>[A-Fa-f0-9-]+)'
|
||||
|
||||
@@ -19,79 +17,6 @@ REQUEST_STUDENT_KEY = 'student_key'
|
||||
ENABLE_ENROLLMENT_RESET_FLAG = 'ENABLE_ENROLLMENT_RESET'
|
||||
|
||||
|
||||
class _EnrollmentErrorStatuses(object):
|
||||
"""
|
||||
Error statuses common to program and program-course enrollments responses.
|
||||
"""
|
||||
|
||||
# Same student key supplied more than once.
|
||||
DUPLICATED = 'duplicated'
|
||||
|
||||
# Requested target status is invalid
|
||||
INVALID_STATUS = "invalid-status"
|
||||
|
||||
# In the case of a POST request, the enrollment already exists.
|
||||
CONFLICT = "conflict"
|
||||
|
||||
# Although the request is syntactically valid,
|
||||
# the change being made is not supported.
|
||||
# For example, it may be illegal to change a user's status back to A
|
||||
# after changing it to B, where A and B are two hypothetical enrollment
|
||||
# statuses.
|
||||
ILLEGAL_OPERATION = "illegal-operation"
|
||||
|
||||
# Could not modify program enrollment or create program-course
|
||||
# enrollment because the student is not enrolled in the program in the
|
||||
# first place.
|
||||
NOT_IN_PROGRAM = "not-in-program"
|
||||
|
||||
# Something unexpected went wrong.
|
||||
# If API users are seeing this, we need to investigate.
|
||||
INTERNAL_ERROR = "internal-error"
|
||||
|
||||
__ALL__ = (
|
||||
DUPLICATED,
|
||||
INVALID_STATUS,
|
||||
CONFLICT,
|
||||
ILLEGAL_OPERATION,
|
||||
NOT_IN_PROGRAM,
|
||||
INTERNAL_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class ProgramResponseStatuses(
|
||||
ProgramEnrollmentStatuses,
|
||||
_EnrollmentErrorStatuses,
|
||||
):
|
||||
"""
|
||||
Valid program enrollment response statuses.
|
||||
|
||||
Combines error statuses and OK statuses.
|
||||
"""
|
||||
__OK__ = ProgramEnrollmentStatuses.__ALL__
|
||||
__ERRORS__ = _EnrollmentErrorStatuses.__ALL__
|
||||
__ALL__ = __OK__ + __ERRORS__
|
||||
|
||||
|
||||
class ProgramCourseResponseStatuses(
|
||||
ProgramCourseEnrollmentStatuses,
|
||||
_EnrollmentErrorStatuses,
|
||||
):
|
||||
"""
|
||||
Valid program-course enrollment response statuses.
|
||||
|
||||
Combines error statuses and OK statuses.
|
||||
"""
|
||||
|
||||
# Could not modify program-course enrollment because the user
|
||||
# is not enrolled in the course in the first place.
|
||||
NOT_FOUND = "not-found"
|
||||
|
||||
__OK__ = ProgramCourseEnrollmentStatuses.__ALL__
|
||||
__ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
|
||||
__ALL__ = __OK__ + __ERRORS__
|
||||
|
||||
|
||||
class CourseRunProgressStatuses(object):
|
||||
"""
|
||||
Statuses that a course run can be in with respect to user progress.
|
||||
|
||||
@@ -6,7 +6,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
from rest_framework import serializers
|
||||
from six import text_type
|
||||
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
|
||||
from .constants import CourseRunProgressStatuses
|
||||
@@ -45,6 +44,32 @@ class ProgramEnrollmentSerializer(serializers.Serializer):
|
||||
return bool(obj.user)
|
||||
|
||||
|
||||
class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
|
||||
"""
|
||||
Base fields for all program enrollment related serializers.
|
||||
"""
|
||||
student_key = serializers.CharField(allow_blank=False, source='external_user_key')
|
||||
# We could have made this a ChoiceField on ProgramEnrollmentStatuses.__ALL__;
|
||||
# however, we instead check statuses in api/writing.py,
|
||||
# returning INVALID_STATUS for individual bad statuses instead of raising
|
||||
# a ValidationError for the entire request.
|
||||
status = serializers.CharField(allow_blank=False)
|
||||
|
||||
|
||||
class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment creation requests.
|
||||
"""
|
||||
curriculum_uuid = serializers.UUIDField()
|
||||
|
||||
|
||||
class ProgramEnrollmentUpdateRequestSerializer(ProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment update requests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for displaying program-course enrollments.
|
||||
@@ -67,40 +92,16 @@ class ProgramCourseEnrollmentSerializer(serializers.Serializer):
|
||||
return text_type(obj.program_enrollment.curriculum_uuid)
|
||||
|
||||
|
||||
class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
|
||||
"""
|
||||
Base fields for all program enrollment related serializers.
|
||||
"""
|
||||
student_key = serializers.CharField()
|
||||
status = serializers.ChoiceField(
|
||||
allow_blank=False,
|
||||
choices=ProgramEnrollmentStatuses.__ALL__,
|
||||
)
|
||||
|
||||
|
||||
class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment creation requests.
|
||||
"""
|
||||
curriculum_uuid = serializers.UUIDField()
|
||||
|
||||
|
||||
class ProgramEnrollmentModifyRequestSerializer(ProgramEnrollmentRequestMixin):
|
||||
"""
|
||||
Serializer for program enrollment modification requests
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidStatusMixin):
|
||||
"""
|
||||
Serializer for request to create a ProgramCourseEnrollment
|
||||
"""
|
||||
student_key = serializers.CharField(allow_blank=False)
|
||||
status = serializers.ChoiceField(
|
||||
allow_blank=False,
|
||||
choices=ProgramCourseEnrollmentStatuses.__ALL__,
|
||||
)
|
||||
student_key = serializers.CharField(allow_blank=False, source='external_user_key')
|
||||
# We could have made this a ChoiceField on ProgramCourseEnrollmentStatuses.__ALL__;
|
||||
# however, we instead check statuses in api/writing.py,
|
||||
# returning INVALID_STATUS for individual bad statuses instead of raising
|
||||
# a ValidationError for the entire request.
|
||||
status = serializers.CharField(allow_blank=False)
|
||||
|
||||
|
||||
class ProgramCourseGradeSerializer(serializers.Serializer):
|
||||
@@ -110,7 +111,7 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
|
||||
Meant to be used with BaseProgramCourseGrade.
|
||||
"""
|
||||
# Required
|
||||
student_key = serializers.CharField()
|
||||
student_key = serializers.SerializerMethodField()
|
||||
|
||||
# From ProgramCourseGradeOk only
|
||||
passed = serializers.BooleanField(required=False)
|
||||
@@ -120,6 +121,9 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
|
||||
# From ProgramCourseGradeError only
|
||||
error = serializers.CharField(required=False)
|
||||
|
||||
def get_student_key(self, obj):
|
||||
return obj.program_course_enrollment.program_enrollment.external_user_key
|
||||
|
||||
|
||||
class DueDateSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -158,62 +162,3 @@ class CourseRunOverviewListSerializer(serializers.Serializer):
|
||||
Serializer for a list of course run overviews.
|
||||
"""
|
||||
course_runs = serializers.ListField(child=CourseRunOverviewSerializer())
|
||||
|
||||
|
||||
# TODO: The following classes are not serializers, and should probably
|
||||
# be moved to api.py as part of EDUCATOR-4321.
|
||||
|
||||
|
||||
class BaseProgramCourseGrade(object):
|
||||
"""
|
||||
Base for either a courserun grade or grade-loading failure.
|
||||
|
||||
Can be passed to ProgramCourseGradeResultSerializer.
|
||||
"""
|
||||
is_error = None # Override in subclass
|
||||
|
||||
def __init__(self, program_course_enrollment):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment,
|
||||
create a BaseProgramCourseGradeResult instance.
|
||||
"""
|
||||
self.student_key = (
|
||||
program_course_enrollment.program_enrollment.external_user_key
|
||||
)
|
||||
|
||||
|
||||
class ProgramCourseGradeOk(BaseProgramCourseGrade):
|
||||
"""
|
||||
Represents a courserun grade for a user enrolled through a program.
|
||||
"""
|
||||
is_error = False
|
||||
|
||||
def __init__(self, program_course_enrollment, course_grade):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment and course grade object,
|
||||
create a ProgramCourseGradeOk.
|
||||
"""
|
||||
super(ProgramCourseGradeOk, self).__init__(
|
||||
program_course_enrollment
|
||||
)
|
||||
self.passed = course_grade.passed
|
||||
self.percent = course_grade.percent
|
||||
self.letter_grade = course_grade.letter_grade
|
||||
|
||||
|
||||
class ProgramCourseGradeError(BaseProgramCourseGrade):
|
||||
"""
|
||||
Represents a failure to load a courserun grade for a user enrolled through
|
||||
a program.
|
||||
"""
|
||||
is_error = True
|
||||
|
||||
def __init__(self, program_course_enrollment, exception=None):
|
||||
"""
|
||||
Given a ProgramCourseEnrollment and an Exception,
|
||||
create a ProgramCourseGradeError.
|
||||
"""
|
||||
super(ProgramCourseGradeError, self).__init__(
|
||||
program_course_enrollment
|
||||
)
|
||||
self.error = text_type(exception) if exception else "Unknown error"
|
||||
|
||||
@@ -4,6 +4,7 @@ Unit tests for ProgramEnrollment views.
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -28,9 +29,12 @@ from course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory
|
||||
from lms.djangoapps.grades.api import CourseGradeFactory
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramCourseOperationStatuses as CourseStatuses
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramOperationStatuses as ProgramStatuses
|
||||
from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
|
||||
from lms.djangoapps.program_enrollments.utils import ProviderDoesNotExistException
|
||||
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL
|
||||
from openedx.core.djangoapps.catalog.tests.factories import (
|
||||
CourseFactory,
|
||||
@@ -55,11 +59,18 @@ from ..constants import (
|
||||
REQUEST_STUDENT_KEY,
|
||||
CourseRunProgressStatuses
|
||||
)
|
||||
from ..constants import ProgramCourseResponseStatuses as CourseStatuses
|
||||
from ..constants import ProgramResponseStatuses as ProgramStatuses
|
||||
|
||||
_REST_API_MOCK_FMT = 'lms.djangoapps.program_enrollments.rest_api.{}'
|
||||
_VIEW_MOCK_FMT = _REST_API_MOCK_FMT.format('v1.views.{}')
|
||||
_DJANGOAPP_PATCH_FORMAT = 'lms.djangoapps.program_enrollments.{}'
|
||||
_REST_API_PATCH_FORMAT = _DJANGOAPP_PATCH_FORMAT.format('rest_api.v1.{}')
|
||||
_VIEW_PATCH_FORMAT = _REST_API_PATCH_FORMAT.format('views.{}')
|
||||
|
||||
|
||||
_get_users_patch_path = _DJANGOAPP_PATCH_FORMAT.format('api.writing.get_users_by_external_keys')
|
||||
_patch_get_users = mock.patch(
|
||||
_get_users_patch_path,
|
||||
autospec=True,
|
||||
return_value=defaultdict(lambda: None),
|
||||
)
|
||||
|
||||
|
||||
class ProgramCacheMixin(CacheIsolationMixin):
|
||||
@@ -86,8 +97,9 @@ class EnrollmentsDataMixin(ProgramCacheMixin):
|
||||
def setUpClass(cls):
|
||||
super(EnrollmentsDataMixin, cls).setUpClass()
|
||||
cls.start_cache_isolation()
|
||||
cls.organization_key = "orgkey"
|
||||
cls.organization_key = "testorg"
|
||||
catalog_org = OrganizationFactory(key=cls.organization_key)
|
||||
LMSOrganizationFactory(short_name=cls.organization_key)
|
||||
cls.program_uuid = UUID('00000000-1111-2222-3333-444444444444')
|
||||
cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}'
|
||||
cls.curriculum_uuid = UUID('aaaaaaaa-1111-2222-3333-444444444444')
|
||||
@@ -334,7 +346,6 @@ class ProgramEnrollmentsGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
|
||||
""" Mixin class that defines common tests for program enrollment write endpoints """
|
||||
add_uuid = False
|
||||
success_status = 200
|
||||
|
||||
view_name = 'programs_api:v1:program_enrollments'
|
||||
|
||||
@@ -381,8 +392,7 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
|
||||
json.dumps([{'status': 'enrolled'}]),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
self.assertEqual(response.data, 'invalid enrollment record')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_program_unauthorized(self):
|
||||
student = UserFactory.create(password='password')
|
||||
@@ -414,22 +424,17 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
|
||||
|
||||
response = self.request(url, json.dumps(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_extra_field(self):
|
||||
self.student_enrollment('pending', 'learner-01', prepare_student=True)
|
||||
enrollment = self.student_enrollment('enrolled', 'learner-01')
|
||||
enrollment['favorite_pokemon'] = 'bulbasaur'
|
||||
enrollments = [enrollment]
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
with _patch_get_users:
|
||||
url = self.get_url()
|
||||
response = self.request(url, json.dumps(enrollments), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertDictEqual(
|
||||
response.data,
|
||||
{'learner-01': 'enrolled'}
|
||||
@@ -442,8 +447,6 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
Tests for the ProgramEnrollment view POST method.
|
||||
"""
|
||||
add_uuid = True
|
||||
success_status = status.HTTP_201_CREATED
|
||||
success_status = 201
|
||||
|
||||
view_name = 'programs_api:v1:program_enrollments'
|
||||
|
||||
@@ -470,14 +473,10 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
]
|
||||
|
||||
url = self.get_url(program_uuid=0)
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
with _patch_get_users:
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for i in range(3):
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i])
|
||||
@@ -499,12 +498,14 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
user = User.objects.create_user('test_user', 'test@example.com', 'password')
|
||||
url = self.get_url()
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=user
|
||||
_get_users_patch_path,
|
||||
autospec=True,
|
||||
return_value={'abc1': user},
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
response = self.client.post(
|
||||
url, json.dumps(post_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key='abc1')
|
||||
self.assertEqual(enrollment.external_user_key, 'abc1')
|
||||
self.assertEqual(enrollment.program_uuid, self.program_uuid)
|
||||
@@ -523,13 +524,13 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
|
||||
url = self.get_url()
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
side_effect=ProviderDoesNotExistException()
|
||||
_get_users_patch_path,
|
||||
autospec=True,
|
||||
side_effect=ProviderDoesNotExistException(None),
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
for i in range(3):
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key='abc{}'.format(i))
|
||||
@@ -546,7 +547,6 @@ class ProgramEnrollmentsPatchTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
Tests for the ProgramEnrollment view PATCH method.
|
||||
"""
|
||||
add_uuid = False
|
||||
success_status = status.HTTP_200_OK
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentsPatchTests, self).setUp()
|
||||
@@ -692,19 +692,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
Tests for the ProgramEnrollment view PATCH method.
|
||||
"""
|
||||
add_uuid = True
|
||||
success_status = status.HTTP_200_OK
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramEnrollmentsPutTests, self).setUp()
|
||||
self.request = self.client.put
|
||||
self.client.login(username=self.global_staff.username, password='password')
|
||||
patch_get_user = mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=None
|
||||
)
|
||||
self.mock_get_user = patch_get_user.start()
|
||||
self.addCleanup(patch_get_user.stop)
|
||||
|
||||
def prepare_student(self, key):
|
||||
ProgramEnrollment.objects.create(
|
||||
@@ -730,8 +722,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
)
|
||||
|
||||
url = self.get_url()
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
with _patch_get_users:
|
||||
response = self.client.put(
|
||||
url, json.dumps(request_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(5, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
@@ -755,8 +750,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
)
|
||||
|
||||
url = self.get_url()
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
with _patch_get_users:
|
||||
response = self.client.put(
|
||||
url, json.dumps(request_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(4, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
@@ -870,6 +868,7 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
|
||||
)
|
||||
|
||||
def test_invalid_status(self):
|
||||
self.prepare_student('learner-1')
|
||||
request_data = [self.learner_enrollment('learner-1', 'this-is-not-a-status')]
|
||||
response = self.request(self.default_url, request_data)
|
||||
self.assertEqual(422, response.status_code)
|
||||
@@ -885,7 +884,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
|
||||
def test_422_unprocessable_entity_bad_data(self, request_data):
|
||||
response = self.request(self.default_url, request_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('invalid enrollment record', response.data)
|
||||
|
||||
@ddt.data(
|
||||
[{'status': 'pending'}],
|
||||
@@ -897,7 +895,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
|
||||
request_data.extend(bad_records)
|
||||
response = self.request(self.default_url, request_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('invalid enrollment record', response.data)
|
||||
|
||||
def test_extra_field(self):
|
||||
self.prepare_student('learner-1')
|
||||
@@ -1190,7 +1187,7 @@ class ProgramCourseEnrollmentsModifyMixin(ProgramCourseEnrollmentsMixin):
|
||||
self.assert_program_course_enrollment('learner-4', 'active', False)
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
|
||||
class ProgramCourseEnrollmentsPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
|
||||
""" Tests for course enrollment PATCH """
|
||||
|
||||
def request(self, path, data, **kwargs):
|
||||
@@ -1230,19 +1227,6 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
"""
|
||||
view_name = 'programs_api:v1:program_course_grades'
|
||||
|
||||
@staticmethod
|
||||
def mock_course_grade(percent=75.0, passed=True, letter_grade='B'):
|
||||
return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_204_no_grades_to_return(self, mock_course_grade_factory):
|
||||
mock_course_grade_factory.return_value.iter.return_value = []
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(response.data['results'], [])
|
||||
|
||||
def test_401_if_unauthenticated(self):
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
response = self.client.get(url)
|
||||
@@ -1261,20 +1245,32 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_200_grades_with_no_exceptions(self, mock_course_grade_factory):
|
||||
def test_204_no_grades_to_return(self):
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
with self.patch_grades_with({}):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(response.data['results'], [])
|
||||
|
||||
def test_200_grades_with_no_exceptions(self):
|
||||
other_student = UserFactory.create(username='other_student')
|
||||
self.create_program_and_course_enrollments('student-key', user=self.student)
|
||||
self.create_program_and_course_enrollments('other-student-key', user=other_student)
|
||||
mock_course_grades = [
|
||||
(self.student, self.mock_course_grade(), None),
|
||||
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
|
||||
]
|
||||
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
|
||||
|
||||
mock_grades_by_user = {
|
||||
self.student: (
|
||||
self.mock_grade(),
|
||||
None
|
||||
),
|
||||
other_student: (
|
||||
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
|
||||
None
|
||||
),
|
||||
}
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
response = self.client.get(url)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1292,20 +1288,21 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
]
|
||||
self.assertEqual(response.data['results'], expected_results)
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_207_grades_with_some_exceptions(self, mock_course_grade_factory):
|
||||
def test_207_grades_with_some_exceptions(self):
|
||||
other_student = UserFactory.create(username='other_student')
|
||||
self.create_program_and_course_enrollments('student-key', user=self.student)
|
||||
self.create_program_and_course_enrollments('other-student-key', user=other_student)
|
||||
mock_course_grades = [
|
||||
(self.student, None, Exception('Bad Data')),
|
||||
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
|
||||
]
|
||||
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
|
||||
|
||||
mock_grades_by_user = {
|
||||
self.student: (None, Exception('Bad Data')),
|
||||
other_student: (
|
||||
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
|
||||
None,
|
||||
),
|
||||
}
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
response = self.client.get(url)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1321,20 +1318,18 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
]
|
||||
self.assertEqual(response.data['results'], expected_results)
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_422_grades_with_only_exceptions(self, mock_course_grade_factory):
|
||||
def test_422_grades_with_only_exceptions(self):
|
||||
other_student = UserFactory.create(username='other_student')
|
||||
self.create_program_and_course_enrollments('student-key', user=self.student)
|
||||
self.create_program_and_course_enrollments('other-student-key', user=other_student)
|
||||
mock_course_grades = [
|
||||
(self.student, None, Exception('Bad Data')),
|
||||
(other_student, None, Exception('Timeout')),
|
||||
]
|
||||
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
|
||||
|
||||
mock_grades_by_user = {
|
||||
self.student: (None, Exception('Bad Data')),
|
||||
other_student: (None, Exception('Timeout')),
|
||||
}
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
response = self.client.get(url)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1348,6 +1343,26 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
]
|
||||
self.assertEqual(response.data['results'], expected_results)
|
||||
|
||||
@staticmethod
|
||||
def patch_grades_with(grades_by_user):
|
||||
"""
|
||||
Create a patcher the CourseGradeFactory to use the `grades_by_user`
|
||||
to determine the grade for each user.
|
||||
|
||||
Arguments:
|
||||
grades_by_user: dict[User: (CourseGrade, Exception)]
|
||||
"""
|
||||
def patched_iter(self, users, course_key): # pylint: disable=unused-argument
|
||||
return [
|
||||
(user, grades_by_user[user][0], grades_by_user[user][1])
|
||||
for user in users
|
||||
]
|
||||
return mock.patch.object(CourseGradeFactory, 'iter', new=patched_iter)
|
||||
|
||||
@staticmethod
|
||||
def mock_grade(percent=75.0, passed=True, letter_grade='B'):
|
||||
return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
@@ -1388,7 +1403,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
mock_return_value = [program for program in self.mock_program_data if program['type'] == program_type]
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_programs_by_type'),
|
||||
_VIEW_PATCH_FORMAT.format('get_programs_by_type'),
|
||||
autospec=True,
|
||||
return_value=mock_return_value
|
||||
) as mock_get_programs_by_type:
|
||||
@@ -1402,7 +1417,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.course_staff.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
autospec=True,
|
||||
return_value=[self.mock_program_data[0]]
|
||||
) as mock_get_programs:
|
||||
@@ -1421,7 +1436,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.course_staff.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
autospec=True,
|
||||
side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
|
||||
) as mock_get_programs:
|
||||
@@ -1434,7 +1449,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
mock.call(course=other_course_key),
|
||||
], any_order=True)
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('get_programs'), autospec=True, return_value=None)
|
||||
@mock.patch(_VIEW_PATCH_FORMAT.format('get_programs'), autospec=True, return_value=None)
|
||||
def test_learner_200_if_no_programs_enrolled(self, mock_get_programs):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
response = self.client.get(reverse(self.view_name))
|
||||
@@ -1455,7 +1470,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
autospec=True,
|
||||
return_value=self.mock_program_data
|
||||
) as mock_get_programs:
|
||||
@@ -1475,6 +1490,11 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
"""
|
||||
Tests for the ProgramCourseEnrollmentOverview view GET method.
|
||||
"""
|
||||
patch_resume_url = mock.patch(
|
||||
_VIEW_PATCH_FORMAT.format('get_resume_urls_for_enrollments'),
|
||||
autospec=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(ProgramCourseEnrollmentOverviewGetTests, cls).setUpClass()
|
||||
@@ -1614,16 +1634,14 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
expected_course_run_ids.add(text_type(other_course_key))
|
||||
self.assertEqual(expected_course_run_ids, actual_course_run_ids)
|
||||
|
||||
_GET_RESUME_URL = _VIEW_MOCK_FMT.format('get_resume_urls_for_enrollments')
|
||||
|
||||
@mock.patch(_GET_RESUME_URL)
|
||||
@patch_resume_url
|
||||
def test_blank_resume_url_omitted(self, mock_get_resume_urls):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
mock_get_resume_urls.return_value = {self.course_id: ''}
|
||||
response = self.client.get(self.get_url(self.program_uuid))
|
||||
self.assertNotIn('resume_course_run_url', response.data['course_runs'][0])
|
||||
|
||||
@mock.patch(_GET_RESUME_URL)
|
||||
@patch_resume_url
|
||||
def test_relative_resume_url_becomes_absolute(self, mock_get_resume_urls):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
resume_url = '/resume-here'
|
||||
@@ -1633,7 +1651,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
self.assertTrue(response_resume_url.startswith("http://testserver"))
|
||||
self.assertTrue(response_resume_url.endswith(resume_url))
|
||||
|
||||
@mock.patch(_GET_RESUME_URL)
|
||||
@patch_resume_url
|
||||
def test_absolute_resume_url_stays_absolute(self, mock_get_resume_urls):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
resume_url = 'http://www.resume.com/'
|
||||
@@ -1968,6 +1986,10 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
reset_enrollments_cmd = 'reset_enrollment_data'
|
||||
reset_users_cmd = 'remove_social_auth_users'
|
||||
|
||||
patch_call_command = mock.patch(
|
||||
_VIEW_PATCH_FORMAT.format('call_command'), autospec=True
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(EnrollmentDataResetViewTests, self).setUp()
|
||||
self.start_cache_isolation()
|
||||
@@ -1989,14 +2011,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
self.end_cache_isolation()
|
||||
super(EnrollmentDataResetViewTests, self).tearDown()
|
||||
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_feature_disabled_by_default(self, mock_call_command):
|
||||
response = self.request(self.organization.short_name)
|
||||
self.assertEqual(response.status_code, status.HTTP_501_NOT_IMPLEMENTED)
|
||||
mock_call_command.assert_has_calls([])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_403_for_non_staff(self, mock_call_command):
|
||||
student = UserFactory.create(username='student', password='password')
|
||||
self.client.login(username=student.username, password='password')
|
||||
@@ -2005,7 +2027,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
mock_call_command.assert_has_calls([])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_reset(self, mock_call_command):
|
||||
programs = [str(uuid4()), str(uuid4())]
|
||||
self.set_org_in_catalog_cache(self.organization, programs)
|
||||
@@ -2018,7 +2040,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_reset_without_idp(self, mock_call_command):
|
||||
organization = LMSOrganizationFactory()
|
||||
programs = [str(uuid4()), str(uuid4())]
|
||||
@@ -2031,14 +2053,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_organization_not_found(self, mock_call_command):
|
||||
response = self.request('yyz')
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
mock_call_command.assert_has_calls([])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_no_programs_doesnt_break(self, mock_call_command):
|
||||
programs = []
|
||||
self.set_org_in_catalog_cache(self.organization, programs)
|
||||
@@ -2050,7 +2072,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
@patch_call_command
|
||||
def test_missing_body_content(self, mock_call_command):
|
||||
response = self.client.post(
|
||||
reverse('programs_api:v1:reset_enrollment_data'),
|
||||
|
||||
@@ -12,7 +12,6 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import UTC
|
||||
from rest_framework import status
|
||||
|
||||
from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
|
||||
from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs, is_course_run_in_program
|
||||
from openedx.core.lib.api.view_utils import verify_course_exists
|
||||
@@ -116,6 +115,28 @@ def verify_course_exists_and_in_program(view_func):
|
||||
return wrapped_function
|
||||
|
||||
|
||||
def get_enrollment_http_code(result_statuses, ok_statuses):
|
||||
"""
|
||||
Given a set of enrollment create/update statuses,
|
||||
return the appropriate HTTP status code.
|
||||
|
||||
Arguments:
|
||||
result_statuses (sequence[str]): set of enrollment operation statuses
|
||||
(for example, 'enrolled', 'not-in-program', etc.)
|
||||
ok_statuses: sequence[str]: set of 'OK' (non-error) statuses
|
||||
"""
|
||||
result_status_set = set(result_statuses)
|
||||
ok_status_set = set(ok_statuses)
|
||||
if not result_status_set:
|
||||
return status.HTTP_204_NO_CONTENT
|
||||
if result_status_set.issubset(ok_status_set):
|
||||
return status.HTTP_200_OK
|
||||
elif result_status_set & ok_status_set:
|
||||
return status.HTTP_207_MULTI_STATUS
|
||||
else:
|
||||
return status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
def get_course_run_status(course_overview, certificate_info):
|
||||
"""
|
||||
Get the progress status of a course run, given the state of a user's
|
||||
|
||||
@@ -4,8 +4,6 @@ ProgramEnrollment Views
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from ccx_keys.locator import CCXLocator
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -17,29 +15,30 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from organizations.models import Organization
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from six import text_type
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.bulk_email.api import get_emails_enabled
|
||||
from lms.djangoapps.certificates.api import get_certificate_for_user
|
||||
from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates
|
||||
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
|
||||
from lms.djangoapps.program_enrollments.api import (
|
||||
fetch_program_course_enrollments,
|
||||
fetch_program_enrollments,
|
||||
fetch_program_enrollments_by_student
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from lms.djangoapps.program_enrollments.utils import (
|
||||
ProviderDoesNotExistException,
|
||||
fetch_program_enrollments_by_student,
|
||||
get_provider_slug,
|
||||
get_user_by_program_id
|
||||
get_saml_provider_for_organization,
|
||||
iter_program_course_grades,
|
||||
write_program_course_enrollments,
|
||||
write_program_enrollments
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.constants import (
|
||||
ProgramCourseOperationStatuses,
|
||||
ProgramEnrollmentStatuses,
|
||||
ProgramOperationStatuses
|
||||
)
|
||||
from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
|
||||
from openedx.core.djangoapps.catalog.utils import (
|
||||
course_run_keys_for_program,
|
||||
get_programs,
|
||||
@@ -55,38 +54,89 @@ from student.models import CourseEnrollment
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole
|
||||
from util.query import read_replica_or_default
|
||||
|
||||
from .constants import (
|
||||
ENABLE_ENROLLMENT_RESET_FLAG,
|
||||
MAX_ENROLLMENT_RECORDS,
|
||||
ProgramCourseResponseStatuses,
|
||||
ProgramResponseStatuses
|
||||
)
|
||||
from .constants import ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS
|
||||
from .serializers import (
|
||||
CourseRunOverviewListSerializer,
|
||||
ProgramCourseEnrollmentRequestSerializer,
|
||||
ProgramCourseEnrollmentSerializer,
|
||||
ProgramCourseGradeError,
|
||||
ProgramCourseGradeOk,
|
||||
ProgramCourseGradeSerializer,
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
ProgramEnrollmentSerializer
|
||||
ProgramEnrollmentSerializer,
|
||||
ProgramEnrollmentUpdateRequestSerializer
|
||||
)
|
||||
from .utils import (
|
||||
ProgramCourseSpecificViewMixin,
|
||||
ProgramEnrollmentPagination,
|
||||
ProgramSpecificViewMixin,
|
||||
get_course_run_status,
|
||||
get_enrollment_http_code,
|
||||
verify_course_exists_and_in_program,
|
||||
verify_program_exists
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EnrollmentWriteMixin(object):
|
||||
"""
|
||||
Common functionality for viewsets with enrollment-writing POST/PATCH/PUT methods.
|
||||
|
||||
Provides a `handle_write_request` utility method, which depends on the
|
||||
definitions of `serializer_class_by_write_method`, `ok_write_statuses`,
|
||||
and `perform_enrollment_write`.
|
||||
"""
|
||||
create_update_by_write_method = {
|
||||
'POST': (True, False),
|
||||
'PATCH': (False, True),
|
||||
'PUT': (True, True),
|
||||
}
|
||||
|
||||
# Set in subclasses
|
||||
serializer_class_by_write_method = "set-me-to-a-dict-with-http-method-keys"
|
||||
ok_write_statuses = "set-me-to-a-set"
|
||||
|
||||
def handle_write_request(self):
|
||||
"""
|
||||
Create/modify program enrollments.
|
||||
Returns: Response
|
||||
"""
|
||||
serializer_class = self.serializer_class_by_write_method[self.request.method]
|
||||
serializer = serializer_class(data=self.request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
num_requests = len(self.request.data)
|
||||
if num_requests > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
'{} enrollments requested, but limit is {}.'.format(
|
||||
MAX_ENROLLMENT_RECORDS, num_requests
|
||||
),
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
)
|
||||
create, update = self.create_update_by_write_method[self.request.method]
|
||||
results = self.perform_enrollment_write(
|
||||
serializer.validated_data, create, update
|
||||
)
|
||||
http_code = get_enrollment_http_code(
|
||||
results.values(), self.ok_write_statuses
|
||||
)
|
||||
return Response(status=http_code, data=results, content_type='application/json')
|
||||
|
||||
def perform_enrollment_write(self, enrollment_requests, create, update):
|
||||
"""
|
||||
Perform the write operation. Implemented in subclasses.
|
||||
|
||||
Arguments:
|
||||
enrollment_requests: list[dict]
|
||||
create (bool)
|
||||
update (bool)
|
||||
|
||||
Returns: dict[str: str]
|
||||
Map from external keys to enrollment write statuses.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class ProgramEnrollmentsView(
|
||||
EnrollmentWriteMixin,
|
||||
DeveloperErrorViewMixin,
|
||||
ProgramCourseSpecificViewMixin,
|
||||
ProgramSpecificViewMixin,
|
||||
PaginatedAPIView,
|
||||
):
|
||||
"""
|
||||
@@ -180,7 +230,7 @@ class ProgramEnrollmentsView(
|
||||
* 'duplicated' - the request body listed the same learner twice
|
||||
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
|
||||
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
|
||||
* 201: CREATED - All students were successfully enrolled.
|
||||
* 200: OK - All students were successfully enrolled.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'enrolled',
|
||||
@@ -244,7 +294,7 @@ class ProgramEnrollmentsView(
|
||||
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
|
||||
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
|
||||
* 'not-in-program' - the user is not in the program and cannot be updated
|
||||
* 201: CREATED - All students were successfully enrolled.
|
||||
* 200: OK - All students were successfully enrolled.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'enrolled',
|
||||
@@ -274,187 +324,65 @@ class ProgramEnrollmentsView(
|
||||
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
|
||||
pagination_class = ProgramEnrollmentPagination
|
||||
|
||||
# Overridden from `EnrollmentWriteMixin`
|
||||
serializer_class_by_write_method = {
|
||||
'POST': ProgramEnrollmentCreateRequestSerializer,
|
||||
'PATCH': ProgramEnrollmentUpdateRequestSerializer,
|
||||
'PUT': ProgramEnrollmentCreateRequestSerializer,
|
||||
}
|
||||
ok_write_statuses = ProgramOperationStatuses.__OK__
|
||||
|
||||
@verify_program_exists
|
||||
def get(self, request, program_uuid=None):
|
||||
""" Defines the GET list endpoint for ProgramEnrollment objects. """
|
||||
enrollments = fetch_program_enrollments(
|
||||
program_uuid
|
||||
self.program_uuid
|
||||
).using(read_replica_or_default())
|
||||
paginated_enrollments = self.paginate_queryset(enrollments)
|
||||
serializer = ProgramEnrollmentSerializer(paginated_enrollments, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@verify_program_exists
|
||||
def post(self, request, *args, **kwargs):
|
||||
def post(self, request, program_uuid=None):
|
||||
"""
|
||||
Create program enrollments for a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_program_enrollment,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
@verify_program_exists
|
||||
def patch(self, request, **kwargs):
|
||||
def patch(self, request, program_uuid=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Modify program enrollments for a list of learners
|
||||
Update program enrollments for a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
self.modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
@verify_program_exists
|
||||
def put(self, request, **kwargs):
|
||||
def put(self, request, program_uuid=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Create/modify program enrollments for a list of learners
|
||||
Create/update program enrollments for a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_or_modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
|
||||
def perform_enrollment_write(self, enrollment_requests, create, update):
|
||||
"""
|
||||
Validates the given enrollment record and checks that it isn't a duplicate
|
||||
Perform the program enrollment write operation.
|
||||
Overridden from `EnrollmentWriteMixin`.
|
||||
|
||||
Arguments:
|
||||
enrollment_requests: list[dict]
|
||||
create (bool)
|
||||
update (bool)
|
||||
|
||||
Returns: dict[str: str]
|
||||
Map from external keys to enrollment write statuses.
|
||||
"""
|
||||
student_key = enrollment['student_key']
|
||||
if student_key in seen_student_keys:
|
||||
return ProgramResponseStatuses.DUPLICATED
|
||||
seen_student_keys.add(student_key)
|
||||
enrollment_serializer = serializer_class(data=enrollment)
|
||||
try:
|
||||
enrollment_serializer.is_valid(raise_exception=True)
|
||||
except ValidationError:
|
||||
if enrollment_serializer.has_invalid_status():
|
||||
return ProgramResponseStatuses.INVALID_STATUS
|
||||
else:
|
||||
raise
|
||||
|
||||
def create_or_modify_enrollments(self, request, program_uuid, serializer_class, operation, success_status):
|
||||
"""
|
||||
Process a list of program course enrollment request objects
|
||||
and create or modify enrollments based on method
|
||||
"""
|
||||
results = {}
|
||||
seen_student_keys = set()
|
||||
enrollments = []
|
||||
|
||||
if not isinstance(request.data, list):
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
if len(request.data) > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
'enrollment limit {}'.format(MAX_ENROLLMENT_RECORDS),
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
|
||||
)
|
||||
|
||||
try:
|
||||
for enrollment_request in request.data:
|
||||
error_status = self.validate_enrollment_request(enrollment_request, seen_student_keys, serializer_class)
|
||||
if error_status:
|
||||
results[enrollment_request["student_key"]] = error_status
|
||||
else:
|
||||
enrollments.append(enrollment_request)
|
||||
except KeyError: # student_key is not in enrollment_request
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
except TypeError: # enrollment_request isn't a dict
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
except ValidationError: # there was some other error raised by the serializer
|
||||
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
|
||||
for enrollment in enrollments:
|
||||
student_key = enrollment["student_key"]
|
||||
if student_key in results and results[student_key] == ProgramResponseStatuses.DUPLICATED:
|
||||
continue
|
||||
try:
|
||||
program_enrollment = program_enrollments[student_key]
|
||||
except KeyError:
|
||||
program_enrollment = None
|
||||
results[student_key] = operation(enrollment, program_uuid, program_enrollment)
|
||||
|
||||
return self._get_created_or_updated_response(results, success_status)
|
||||
|
||||
def create_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
"""
|
||||
Create new ProgramEnrollment, unless the learner is already enrolled in the program
|
||||
"""
|
||||
if program_enrollment:
|
||||
return ProgramResponseStatuses.CONFLICT
|
||||
|
||||
student_key = request_data.get('student_key')
|
||||
try:
|
||||
user = get_user_by_program_id(student_key, program_uuid)
|
||||
except ProviderDoesNotExistException:
|
||||
# IDP has not yet been set up, just create waiting enrollments
|
||||
user = None
|
||||
|
||||
enrollment = ProgramEnrollment.objects.create(
|
||||
user=user,
|
||||
external_user_key=student_key,
|
||||
program_uuid=program_uuid,
|
||||
curriculum_uuid=request_data.get('curriculum_uuid'),
|
||||
status=request_data.get('status')
|
||||
)
|
||||
return enrollment.status
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
"""
|
||||
Change the status of an existing program enrollment
|
||||
"""
|
||||
if not program_enrollment:
|
||||
return ProgramResponseStatuses.NOT_IN_PROGRAM
|
||||
|
||||
program_enrollment.status = request_data.get('status')
|
||||
program_enrollment.save()
|
||||
return program_enrollment.status
|
||||
|
||||
def create_or_modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
|
||||
if program_enrollment:
|
||||
return self.modify_program_enrollment(request_data, program_uuid, program_enrollment)
|
||||
else:
|
||||
return self.create_program_enrollment(request_data, program_uuid, program_enrollment)
|
||||
|
||||
def get_existing_program_enrollments(self, program_uuid, student_data):
|
||||
""" Returns the existing program enrollments for the given students and program """
|
||||
student_keys = [data['student_key'] for data in student_data]
|
||||
program_enrollments_qs = fetch_program_enrollments(
|
||||
program_uuid=program_uuid, external_user_keys=student_keys
|
||||
)
|
||||
return {e.external_user_key: e for e in program_enrollments_qs}
|
||||
|
||||
def _get_created_or_updated_response(self, response_data, default_status=status.HTTP_201_CREATED):
|
||||
"""
|
||||
Helper method to determine an appropirate HTTP response status code.
|
||||
"""
|
||||
response_status = default_status
|
||||
good_count = len([
|
||||
v for v in response_data.values()
|
||||
if v in ProgramResponseStatuses.__OK__
|
||||
])
|
||||
if not good_count:
|
||||
response_status = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
elif good_count != len(response_data):
|
||||
response_status = status.HTTP_207_MULTI_STATUS
|
||||
|
||||
return Response(
|
||||
status=response_status,
|
||||
data=response_data,
|
||||
content_type='application/json',
|
||||
return write_program_enrollments(
|
||||
self.program_uuid, enrollment_requests, create=create, update=update
|
||||
)
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentsView(
|
||||
EnrollmentWriteMixin,
|
||||
DeveloperErrorViewMixin,
|
||||
ProgramCourseSpecificViewMixin,
|
||||
PaginatedAPIView,
|
||||
@@ -540,6 +468,14 @@ class ProgramCourseEnrollmentsView(
|
||||
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
|
||||
pagination_class = ProgramEnrollmentPagination
|
||||
|
||||
# Overridden from `EnrollmentWriteMixin`
|
||||
serializer_class_by_write_method = {
|
||||
'POST': ProgramCourseEnrollmentRequestSerializer,
|
||||
'PATCH': ProgramCourseEnrollmentRequestSerializer,
|
||||
'PUT': ProgramCourseEnrollmentRequestSerializer,
|
||||
}
|
||||
ok_write_statuses = ProgramCourseOperationStatuses.__OK__
|
||||
|
||||
@verify_course_exists_and_in_program
|
||||
def get(self, request, program_uuid=None, course_id=None):
|
||||
"""
|
||||
@@ -559,11 +495,7 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Enroll a list of students in a course in a program
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.enroll_learner_in_course
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
@verify_course_exists_and_in_program
|
||||
# pylint: disable=unused-argument
|
||||
@@ -571,11 +503,7 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Modify the program course enrollments of a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.modify_learner_enrollment_status
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
@verify_course_exists_and_in_program
|
||||
# pylint: disable=unused-argument
|
||||
@@ -583,140 +511,29 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Create or Update the program course enrollments of a list of learners
|
||||
"""
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.create_or_update_learner_enrollment
|
||||
)
|
||||
return self.handle_write_request()
|
||||
|
||||
def create_or_modify_enrollments(self, request, program_uuid, operation):
|
||||
def perform_enrollment_write(self, enrollment_requests, create, update):
|
||||
"""
|
||||
Process a list of program course enrollment request objects
|
||||
and create or modify enrollments based on method
|
||||
Perform the program enrollment write operation.
|
||||
Overridden from `EnrollmentWriteMixin`.
|
||||
|
||||
Arguments:
|
||||
enrollment_requests: list[dict]
|
||||
create (bool)
|
||||
update (bool)
|
||||
|
||||
Returns: dict[str: str]
|
||||
Map from external keys to enrollment write statuses.
|
||||
"""
|
||||
results = {}
|
||||
seen_student_keys = set()
|
||||
enrollments = []
|
||||
|
||||
if not isinstance(request.data, list):
|
||||
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
|
||||
if len(request.data) > MAX_ENROLLMENT_RECORDS:
|
||||
return Response(
|
||||
'enrollment limit 25', status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
|
||||
)
|
||||
|
||||
try:
|
||||
for enrollment_request in request.data:
|
||||
error_status = self.check_enrollment_request(enrollment_request, seen_student_keys)
|
||||
if error_status:
|
||||
results[enrollment_request["student_key"]] = error_status
|
||||
else:
|
||||
enrollments.append(enrollment_request)
|
||||
except KeyError: # student_key is not in enrollment_request
|
||||
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
|
||||
except TypeError: # enrollment_request isn't a dict
|
||||
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
|
||||
except ValidationError: # there was some other error raised by the serializer
|
||||
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
|
||||
for enrollment in enrollments:
|
||||
student_key = enrollment["student_key"]
|
||||
if student_key in results and results[student_key] == ProgramCourseResponseStatuses.DUPLICATED:
|
||||
continue
|
||||
try:
|
||||
program_enrollment = program_enrollments[student_key]
|
||||
except KeyError:
|
||||
results[student_key] = ProgramCourseResponseStatuses.NOT_IN_PROGRAM
|
||||
else:
|
||||
program_course_enrollment = program_enrollment.get_program_course_enrollment(self.course_key)
|
||||
results[student_key] = operation(enrollment, program_enrollment, program_course_enrollment)
|
||||
|
||||
good_count = sum(
|
||||
1 for _, v in results.items()
|
||||
if v in ProgramCourseResponseStatuses.__OK__
|
||||
)
|
||||
if not good_count:
|
||||
return Response(results, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
if good_count != len(results):
|
||||
return Response(results, status.HTTP_207_MULTI_STATUS)
|
||||
else:
|
||||
return Response(results)
|
||||
|
||||
def check_enrollment_request(self, enrollment, seen_student_keys):
|
||||
"""
|
||||
Checks that the given enrollment record is valid and hasn't been duplicated
|
||||
"""
|
||||
student_key = enrollment['student_key']
|
||||
if student_key in seen_student_keys:
|
||||
return ProgramCourseResponseStatuses.DUPLICATED
|
||||
seen_student_keys.add(student_key)
|
||||
enrollment_serializer = ProgramCourseEnrollmentRequestSerializer(data=enrollment)
|
||||
try:
|
||||
enrollment_serializer.is_valid(raise_exception=True)
|
||||
except ValidationError as e:
|
||||
if enrollment_serializer.has_invalid_status():
|
||||
return ProgramCourseResponseStatuses.INVALID_STATUS
|
||||
else:
|
||||
raise e
|
||||
|
||||
def get_existing_program_enrollments(self, program_uuid, enrollments):
|
||||
"""
|
||||
Parameters:
|
||||
- enrollments: A list of enrollment requests
|
||||
Returns:
|
||||
- Dictionary mapping all student keys in the enrollment requests
|
||||
to that user's existing program enrollment in <self.program>
|
||||
"""
|
||||
external_user_keys = [e["student_key"] for e in enrollments]
|
||||
existing_enrollments = fetch_program_enrollments(
|
||||
program_uuid=program_uuid,
|
||||
external_user_keys=external_user_keys,
|
||||
).prefetch_related('program_course_enrollments')
|
||||
return {enrollment.external_user_key: enrollment for enrollment in existing_enrollments}
|
||||
|
||||
def enroll_learner_in_course(self, enrollment_request, program_enrollment, program_course_enrollment):
|
||||
"""
|
||||
Attempts to enroll the specified user into the course as a part of the
|
||||
given program enrollment with the given status
|
||||
|
||||
Returns the actual status
|
||||
"""
|
||||
if program_course_enrollment:
|
||||
return ProgramCourseResponseStatuses.CONFLICT
|
||||
|
||||
return ProgramCourseEnrollment.create_program_course_enrollment(
|
||||
program_enrollment,
|
||||
return write_program_course_enrollments(
|
||||
self.program_uuid,
|
||||
self.course_key,
|
||||
enrollment_request['status']
|
||||
enrollment_requests,
|
||||
create=create,
|
||||
update=update,
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def modify_learner_enrollment_status(self, enrollment_request, program_enrollment, program_course_enrollment):
|
||||
"""
|
||||
Attempts to modify the specified user's enrollment in the given course
|
||||
in the given program
|
||||
"""
|
||||
if program_course_enrollment is None:
|
||||
return ProgramCourseResponseStatuses.NOT_FOUND
|
||||
return program_course_enrollment.change_status(enrollment_request['status'])
|
||||
|
||||
def create_or_update_learner_enrollment(self, enrollment_request, program_enrollment, program_course_enrollment):
|
||||
"""
|
||||
Attempts to create or update the specified user's enrollment in the given course
|
||||
in the given program
|
||||
"""
|
||||
if program_course_enrollment is None:
|
||||
# create the course enrollment
|
||||
return ProgramCourseEnrollment.create_program_course_enrollment(
|
||||
program_enrollment,
|
||||
self.course_key,
|
||||
enrollment_request['status']
|
||||
)
|
||||
else:
|
||||
# Update course enrollment
|
||||
return program_course_enrollment.change_status(enrollment_request['status'])
|
||||
|
||||
|
||||
class ProgramCourseGradesView(
|
||||
DeveloperErrorViewMixin,
|
||||
@@ -798,83 +615,13 @@ class ProgramCourseGradesView(
|
||||
"""
|
||||
Defines the GET list endpoint for ProgramCourseGrade objects.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
grade_results = self._load_grade_results(program_uuid, course_key)
|
||||
grade_results = list(iter_program_course_grades(
|
||||
self.program_uuid, self.course_key, self.paginate_queryset
|
||||
))
|
||||
serializer = ProgramCourseGradeSerializer(grade_results, many=True)
|
||||
response_code = self._calc_response_code(grade_results)
|
||||
return self.get_paginated_response(serializer.data, status_code=response_code)
|
||||
|
||||
def _load_grade_results(self, program_uuid, course_key):
|
||||
"""
|
||||
Load grades (or grading errors) for a given program courserun.
|
||||
|
||||
Arguments:
|
||||
program_uuid (str)
|
||||
course_key (CourseKey)
|
||||
|
||||
Returns: list[BaseProgramCourseGrade]
|
||||
"""
|
||||
enrollments_qs = fetch_program_course_enrollments(
|
||||
program_uuid=program_uuid,
|
||||
course_key=course_key,
|
||||
realized_only=True,
|
||||
).select_related(
|
||||
'program_enrollment',
|
||||
'program_enrollment__user',
|
||||
).using(read_replica_or_default())
|
||||
paginated_enrollments = self.paginate_queryset(enrollments_qs)
|
||||
if not paginated_enrollments:
|
||||
return []
|
||||
|
||||
# Hint: `zip(*(list))` can be read as "unzip(list)"
|
||||
enrollments, users = zip(*(
|
||||
(enrollment, enrollment.program_enrollment.user)
|
||||
for enrollment in paginated_enrollments
|
||||
))
|
||||
enrollment_grade_pairs = zip(
|
||||
enrollments, self._iter_grades(course_key, list(users))
|
||||
)
|
||||
grade_results = [
|
||||
(
|
||||
ProgramCourseGradeOk(enrollment, grade)
|
||||
if grade
|
||||
else ProgramCourseGradeError(enrollment, exception)
|
||||
)
|
||||
for enrollment, (grade, exception) in enrollment_grade_pairs
|
||||
]
|
||||
return grade_results
|
||||
|
||||
@staticmethod
|
||||
def _iter_grades(course_key, users):
|
||||
"""
|
||||
Load a user grades for a course, using bulk fetching for efficiency.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseKey)
|
||||
users (list[User])
|
||||
|
||||
Returns: iterable[( CourseGradeBase|NoneType, Exception|NoneType )]
|
||||
Iterable of pairs, in same order as `users`.
|
||||
The first item in the pair is the grade, or None if loading the
|
||||
grade failed.
|
||||
The second item in the pair is an exception or None.
|
||||
"""
|
||||
prefetch_course_grades(course_key, users)
|
||||
try:
|
||||
grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
|
||||
for user, course_grade, exception in grades_iter:
|
||||
if not course_grade:
|
||||
fmt = 'Failed to load course grade for user ID {} in {}: {}'
|
||||
err_str = fmt.format(
|
||||
user.id,
|
||||
course_key,
|
||||
text_type(exception) if exception else 'Unknown error'
|
||||
)
|
||||
logger.error(err_str)
|
||||
yield course_grade, exception
|
||||
finally:
|
||||
clear_prefetched_course_grades(course_key)
|
||||
|
||||
@staticmethod
|
||||
def _calc_response_code(grade_results):
|
||||
"""
|
||||
@@ -1250,10 +997,12 @@ class EnrollmentDataResetView(APIView):
|
||||
return Response('organization {} not found'.format(org_key), status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
idp_slug = get_provider_slug(organization)
|
||||
call_command('remove_social_auth_users', idp_slug, force=True)
|
||||
provider = get_saml_provider_for_organization(organization)
|
||||
except ProviderDoesNotExistException:
|
||||
pass
|
||||
else:
|
||||
idp_slug = get_provider_slug(provider)
|
||||
call_command('remove_social_auth_users', idp_slug, force=True)
|
||||
|
||||
programs = get_programs_for_organization(organization=organization.short_name)
|
||||
if programs:
|
||||
|
||||
@@ -11,10 +11,9 @@ from social_django.models import UserSocialAuth
|
||||
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_MISC
|
||||
from student.models import CourseEnrollmentException
|
||||
from third_party_auth.models import SAMLProviderConfig
|
||||
|
||||
from .api import fetch_program_enrollments_by_student
|
||||
from .api import fetch_program_enrollments_by_student, link_program_enrollment_to_lms_user
|
||||
from .models import ProgramEnrollment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -95,17 +94,4 @@ def matriculate_learner(user, uid):
|
||||
authorizing_org.short_name
|
||||
)
|
||||
continue
|
||||
|
||||
enrollment.user = user
|
||||
enrollment.save()
|
||||
for program_course_enrollment in enrollment.program_course_enrollments.all():
|
||||
try:
|
||||
program_course_enrollment.enroll(user)
|
||||
except CourseEnrollmentException as e:
|
||||
logger.warning(
|
||||
'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
|
||||
user.id,
|
||||
program_course_enrollment.id,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
link_program_enrollment_to_lms_user(enrollment, user)
|
||||
|
||||
@@ -6,18 +6,15 @@ from __future__ import absolute_import, unicode_literals
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
import mock
|
||||
from django.db.utils import IntegrityError
|
||||
from django.test import TestCase
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from testfixtures import LogCapture
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
|
||||
from openedx.core.djangoapps.catalog.tests.factories import generate_course_run_key
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from student.models import CourseEnrollment, NonExistentCourseError
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
|
||||
|
||||
@@ -173,89 +170,3 @@ class ProgramCourseEnrollmentModelTests(TestCase):
|
||||
course_enrollment=None,
|
||||
status="active"
|
||||
)
|
||||
|
||||
def test_change_status_no_enrollment(self):
|
||||
program_course_enrollment = self._create_completed_program_course_enrollment()
|
||||
with LogCapture() as capture:
|
||||
program_course_enrollment.course_enrollment = None
|
||||
program_course_enrollment.change_status("inactive")
|
||||
expected_message = "User {} {} {} has no course_enrollment".format(
|
||||
self.user,
|
||||
self.program_enrollment,
|
||||
self.course_key
|
||||
)
|
||||
capture.check(
|
||||
('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
|
||||
)
|
||||
|
||||
def test_change_status_not_active_or_inactive(self):
|
||||
program_course_enrollment = self._create_completed_program_course_enrollment()
|
||||
with LogCapture() as capture:
|
||||
status = "potential-future-status-0123"
|
||||
program_course_enrollment.change_status(status)
|
||||
message = ("Changed {} status to {}, not changing course_enrollment"
|
||||
" status because status is not 'active' or 'inactive'")
|
||||
expected_message = message.format(program_course_enrollment, status)
|
||||
capture.check(
|
||||
('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
|
||||
)
|
||||
|
||||
def test_enroll_new_course_enrollment(self):
|
||||
program_course_enrollment = self._create_waiting_program_course_enrollment()
|
||||
program_course_enrollment.enroll(self.user)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
|
||||
self.assertEqual(course_enrollment.user, self.user)
|
||||
self.assertEqual(course_enrollment.course.id, self.course_key)
|
||||
self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
|
||||
|
||||
def test_enrollment_course_not_found(self):
|
||||
nonexistent_key = 'course-v1:edX+Overview+DNE'
|
||||
program_course_enrollment = ProgramCourseEnrollment.objects.create(
|
||||
program_enrollment=self.program_enrollment,
|
||||
course_key=nonexistent_key,
|
||||
course_enrollment=None,
|
||||
status="active"
|
||||
)
|
||||
|
||||
with LogCapture() as capture:
|
||||
with self.assertRaises(NonExistentCourseError):
|
||||
program_course_enrollment.enroll(self.user)
|
||||
expected = "User {} failed to enroll in non-existent course {}".format(
|
||||
self.user.id, nonexistent_key
|
||||
)
|
||||
capture.check(
|
||||
('lms.djangoapps.program_enrollments.models', 'WARNING', expected)
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(CourseMode.VERIFIED, CourseMode.VERIFIED),
|
||||
(CourseMode.AUDIT, CourseMode.MASTERS),
|
||||
(CourseMode.HONOR, CourseMode.MASTERS)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enroll_existing_course_enrollment(self, original_mode, result_mode):
|
||||
course_enrollment = CourseEnrollmentFactory.create(
|
||||
course_id=self.course_key,
|
||||
user=self.user,
|
||||
mode=original_mode
|
||||
)
|
||||
program_course_enrollment = self._create_waiting_program_course_enrollment()
|
||||
|
||||
program_course_enrollment.enroll(self.user)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
|
||||
self.assertEqual(course_enrollment.user, self.user)
|
||||
self.assertEqual(course_enrollment.course.id, self.course_key)
|
||||
self.assertEqual(course_enrollment.mode, result_mode)
|
||||
|
||||
@mock.patch('student.models.CourseEnrollment.is_enrollment_closed', return_value=True)
|
||||
def test_closed_enrollments_ignored(self, _mock):
|
||||
""" enrolling through program enrollments should ignore checks on enrollment """
|
||||
program_course_enrollment = self._create_waiting_program_course_enrollment()
|
||||
program_course_enrollment.enroll(self.user)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
|
||||
self.assertEqual(course_enrollment.user, self.user)
|
||||
self.assertEqual(course_enrollment.course.id, self.course_key)
|
||||
self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
|
||||
|
||||
@@ -341,27 +341,16 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_log_on_enrollment_failure(self):
|
||||
def test_exception_on_enrollment_failure(self):
|
||||
program_enrollment = self._create_waiting_program_enrollment()
|
||||
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
|
||||
self._create_waiting_course_enrollments(program_enrollment)
|
||||
|
||||
with mock.patch('student.models.CourseEnrollment.enroll') as enrollMock:
|
||||
enrollMock.side_effect = CourseEnrollmentException('something has gone wrong')
|
||||
with LogCapture(logger.name) as log:
|
||||
with pytest.raises(CourseEnrollmentException):
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.user,
|
||||
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
|
||||
)
|
||||
error_template = 'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
|
||||
log.check_present(
|
||||
(
|
||||
logger.name,
|
||||
'WARNING',
|
||||
error_template.format(
|
||||
self.user.id, program_course_enrollments[0].id, 'something has gone wrong'
|
||||
)
|
||||
)
|
||||
with pytest.raises(CourseEnrollmentException):
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.user,
|
||||
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
|
||||
)
|
||||
|
||||
def test_log_on_unexpected_exception(self):
|
||||
@@ -371,7 +360,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
|
||||
program_enrollment = self._create_waiting_program_enrollment()
|
||||
self._create_waiting_course_enrollments(program_enrollment)
|
||||
|
||||
with mock.patch('lms.djangoapps.program_enrollments.models.ProgramCourseEnrollment.enroll') as enrollMock:
|
||||
with mock.patch('lms.djangoapps.program_enrollments.api.linking.enroll_in_masters_track') as enrollMock:
|
||||
enrollMock.side_effect = Exception('unexpected error')
|
||||
with LogCapture(logger.name) as log:
|
||||
with self.assertRaisesRegex(Exception, 'unexpected error'):
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
Unit tests for program_enrollments utils.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from organizations.tests.factories import OrganizationFactory
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
|
||||
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from program_enrollments.utils import (
|
||||
OrganizationDoesNotExistException,
|
||||
ProgramDoesNotExistException,
|
||||
ProviderConfigurationException,
|
||||
ProviderDoesNotExistException,
|
||||
get_user_by_program_id
|
||||
)
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.tests.factories import SAMLProviderConfigFactory
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class GetPlatformUserTests(CacheIsolationTestCase):
|
||||
"""
|
||||
Tests for the get_platform_user function
|
||||
"""
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
def setUp(self):
|
||||
super(GetPlatformUserTests, self).setUp()
|
||||
self.program_uuid = uuid4()
|
||||
self.organization_key = 'ufo'
|
||||
self.external_user_id = '1234'
|
||||
self.user = UserFactory.create()
|
||||
self.setup_catalog_cache(self.program_uuid, self.organization_key)
|
||||
|
||||
def setup_catalog_cache(self, program_uuid, organization_key):
|
||||
"""
|
||||
helper function to initialize a cached program with an single authoring_organization
|
||||
"""
|
||||
catalog_org = CatalogOrganizationFactory.create(key=organization_key)
|
||||
program = ProgramFactory.create(
|
||||
uuid=program_uuid,
|
||||
authoring_organizations=[catalog_org]
|
||||
)
|
||||
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
|
||||
|
||||
def create_social_auth_entry(self, user, provider, external_id):
|
||||
"""
|
||||
helper functio to create a user social auth entry
|
||||
"""
|
||||
UserSocialAuth.objects.create(
|
||||
user=user,
|
||||
uid='{0}:{1}'.format(provider.slug, external_id)
|
||||
)
|
||||
|
||||
def test_get_user_success(self):
|
||||
"""
|
||||
Test lms user is successfully found
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
provider = SAMLProviderConfigFactory.create(organization=organization)
|
||||
self.create_social_auth_entry(self.user, provider, self.external_user_id)
|
||||
|
||||
user = get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
self.assertEquals(user, self.user)
|
||||
|
||||
def test_social_auth_user_not_created(self):
|
||||
"""
|
||||
None should be returned if no lms user exists for an external id
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
SAMLProviderConfigFactory.create(organization=organization)
|
||||
|
||||
user = get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
self.assertIsNone(user)
|
||||
|
||||
def test_catalog_program_does_not_exist(self):
|
||||
"""
|
||||
Test ProgramDoesNotExistException is thrown if the program cache does
|
||||
not include the requested program uuid.
|
||||
"""
|
||||
with pytest.raises(ProgramDoesNotExistException):
|
||||
get_user_by_program_id('school-id-1234', uuid4())
|
||||
|
||||
def test_catalog_program_missing_org(self):
|
||||
"""
|
||||
Test OrganizationDoesNotExistException is thrown if the cached program does not
|
||||
have an authoring organization.
|
||||
"""
|
||||
program = ProgramFactory.create(
|
||||
uuid=self.program_uuid,
|
||||
authoring_organizations=[]
|
||||
)
|
||||
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
|
||||
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
provider = SAMLProviderConfigFactory.create(organization=organization)
|
||||
self.create_social_auth_entry(self.user, provider, self.external_user_id)
|
||||
|
||||
with pytest.raises(OrganizationDoesNotExistException):
|
||||
get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
|
||||
def test_lms_organization_not_found(self):
|
||||
"""
|
||||
Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
|
||||
matching the catalog program's authoring_organization
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name='some_other_org')
|
||||
provider = SAMLProviderConfigFactory.create(organization=organization)
|
||||
self.create_social_auth_entry(self.user, provider, self.external_user_id)
|
||||
|
||||
with pytest.raises(OrganizationDoesNotExistException):
|
||||
get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
|
||||
def test_saml_provider_not_found(self):
|
||||
"""
|
||||
Test an exception is thrown if no SAML provider exists for this program's organization
|
||||
"""
|
||||
OrganizationFactory.create(short_name=self.organization_key)
|
||||
|
||||
with pytest.raises(ProviderDoesNotExistException):
|
||||
get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_multiple_saml_providers(self, second_config_enabled):
|
||||
"""
|
||||
If multiple samlprovider records exist with the same organization
|
||||
an exception is raised
|
||||
"""
|
||||
organization = OrganizationFactory.create(short_name=self.organization_key)
|
||||
provider = SAMLProviderConfigFactory.create(organization=organization)
|
||||
|
||||
self.create_social_auth_entry(self.user, provider, self.external_user_id)
|
||||
|
||||
# create a second active config for the same organization
|
||||
SAMLProviderConfigFactory.create(
|
||||
organization=organization, slug='foox', enabled=second_config_enabled
|
||||
)
|
||||
|
||||
try:
|
||||
get_user_by_program_id(self.external_user_id, self.program_uuid)
|
||||
except ProviderConfigurationException:
|
||||
self.assertTrue(
|
||||
second_config_enabled, 'Unexpected error when second config is disabled'
|
||||
)
|
||||
else:
|
||||
self.assertFalse(
|
||||
second_config_enabled, 'Expected error was not raised when second config is enabled'
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
utility functions for program enrollments
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from organizations.models import Organization
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from third_party_auth.models import SAMLProviderConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgramDoesNotExistException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OrganizationDoesNotExistException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderDoesNotExistException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderConfigurationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_user_by_program_id(external_user_id, program_uuid):
|
||||
"""
|
||||
Returns a User model for an external_user_id with a social auth entry.
|
||||
|
||||
Args:
|
||||
external_user_id: external user id used for social auth
|
||||
program_uuid: a program this user is/will be enrolled in
|
||||
|
||||
Returns:
|
||||
A User object or None, if no user with the given external id for the given organization
|
||||
exists.
|
||||
|
||||
Raises:
|
||||
ProgramDoesNotExistException if no such program exists.
|
||||
OrganizationDoesNotExistException if no organization exists.
|
||||
ProviderDoesNotExistException if there is no SAML provider configured for the related
|
||||
organization.
|
||||
"""
|
||||
program = get_programs(uuid=program_uuid)
|
||||
if program is None:
|
||||
log.error(u'Unable to find catalog program matching uuid [%s]', program_uuid)
|
||||
raise ProgramDoesNotExistException
|
||||
|
||||
try:
|
||||
org_key = program['authoring_organizations'][0]['key']
|
||||
organization = Organization.objects.get(short_name=org_key)
|
||||
except (KeyError, IndexError):
|
||||
log.error(
|
||||
u'Cannot determine authoring organization key for catalog program [%s]', program_uuid
|
||||
)
|
||||
raise OrganizationDoesNotExistException
|
||||
except Organization.DoesNotExist:
|
||||
log.error(u'Unable to find organization for short_name [%s]', org_key)
|
||||
raise OrganizationDoesNotExistException
|
||||
|
||||
return get_user_by_organization(external_user_id, organization)
|
||||
|
||||
|
||||
def get_user_by_organization(external_user_id, organization):
|
||||
"""
|
||||
Returns a User model for an external_user_id with a social auth entry.
|
||||
|
||||
This function finds a matching SAML Provider for the given organization, and looks
|
||||
for a social auth entry with the provided exernal id.
|
||||
|
||||
Args:
|
||||
external_user_id: external user id used for social auth
|
||||
organization: organization providing saml authentication for this user
|
||||
|
||||
Returns:
|
||||
A User object or None, if no user with the given external id for the given organization
|
||||
exists.
|
||||
|
||||
Raises:
|
||||
ProviderDoesNotExistException if there is no SAML provider configured for the related
|
||||
organization.
|
||||
"""
|
||||
provider_slug = get_provider_slug(organization)
|
||||
try:
|
||||
social_auth_uid = '{0}:{1}'.format(provider_slug, external_user_id)
|
||||
return UserSocialAuth.objects.get(uid=social_auth_uid).user
|
||||
except UserSocialAuth.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_provider_slug(organization):
|
||||
"""
|
||||
Returns slug for the currently configured saml provder on an Organization
|
||||
|
||||
Raises:
|
||||
ProviderDoesNotExistsException
|
||||
ProviderConfigurationException
|
||||
"""
|
||||
try:
|
||||
provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
|
||||
except SAMLProviderConfig.DoesNotExist:
|
||||
log.error('No SAML provider found for organization id [%s]', organization.id)
|
||||
raise ProviderDoesNotExistException
|
||||
except SAMLProviderConfig.MultipleObjectsReturned:
|
||||
log.error(
|
||||
'Multiple active SAML configurations found for organization=%s. Expected one.',
|
||||
organization.short_name,
|
||||
)
|
||||
raise ProviderConfigurationException
|
||||
return provider_config.provider_id.strip('saml-')
|
||||
@@ -9,12 +9,13 @@ import itertools
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
import ddt
|
||||
import six
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import signals
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from mock import patch
|
||||
from pytz import UTC
|
||||
@@ -22,7 +23,6 @@ from pytz import UTC
|
||||
from common.test.utils import disable_signal
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from lms.djangoapps.program_enrollments.api import NO_PROGRAM_ENROLLMENT_TEMPLATE
|
||||
from lms.djangoapps.verify_student.models import VerificationDeadline
|
||||
from student.models import ENROLLED_TO_ENROLLED, CourseEnrollment, CourseEnrollmentAttribute, ManualEnrollmentAudit
|
||||
from student.roles import GlobalStaff, SupportStaffRole
|
||||
@@ -446,6 +446,12 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
|
||||
"""
|
||||
Tests for the link_program_enrollments support view.
|
||||
"""
|
||||
patch_render = patch(
|
||||
'support.views.program_enrollments.render_to_response',
|
||||
return_value=HttpResponse(),
|
||||
autospec=True,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
"""Make the user support staff. """
|
||||
super(SupportViewLinkProgramEnrollmentsTests, self).setUp()
|
||||
@@ -454,53 +460,62 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
|
||||
self.program_uuid = str(uuid4())
|
||||
self.text = '0001,user-0001\n0002,user-02'
|
||||
|
||||
def _assert_props(self, field_name, value, response):
|
||||
self.assertIn('"{}": "{}"'.format(field_name, value), six.text_type(response.content, encoding='utf-8'))
|
||||
@patch_render
|
||||
def test_get(self, mocked_render):
|
||||
self.client.get(self.url)
|
||||
render_call_dict = mocked_render.call_args[0][1]
|
||||
assert render_call_dict == {
|
||||
'successes': [],
|
||||
'errors': [],
|
||||
'program_uuid': '',
|
||||
'text': ''
|
||||
}
|
||||
|
||||
def _assert_props_list(self, field_name, values, response):
|
||||
def test_rendering(self):
|
||||
"""
|
||||
Assert that that page is being rendered with a specific list of props
|
||||
Test the view without mocking out the rendering like the rest of the tests.
|
||||
"""
|
||||
values_str = ''
|
||||
if values:
|
||||
values_str = '", "'.join(values)
|
||||
values_str = '"{}"'.format(values_str)
|
||||
self.assertIn(u'"{}": [{}]'.format(field_name, values_str), six.text_type(response.content, encoding='utf-8'))
|
||||
|
||||
def test_get(self):
|
||||
response = self.client.get(self.url)
|
||||
self._assert_props_list('successes', [], response)
|
||||
self._assert_props_list('errors', [], response)
|
||||
self._assert_props('programUUID', '', response)
|
||||
self._assert_props('text', '', response)
|
||||
content = six.text_type(response.content, encoding='utf-8')
|
||||
assert '"programUUID": ""' in content
|
||||
assert '"text": ""' in content
|
||||
|
||||
def test_invalid_uuid(self):
|
||||
response = self.client.post(self.url, data={
|
||||
@patch_render
|
||||
def test_invalid_uuid(self, mocked_render):
|
||||
self.client.post(self.url, data={
|
||||
'program_uuid': 'notauuid',
|
||||
'text': self.text,
|
||||
})
|
||||
self._assert_props_list('errors', [u'badly formed hexadecimal UUID string'], response)
|
||||
msg = u"Supplied program UUID 'notauuid' is not a valid UUID."
|
||||
render_call_dict = mocked_render.call_args[0][1]
|
||||
assert render_call_dict['errors'] == [msg]
|
||||
|
||||
@ddt.unpack
|
||||
@patch_render
|
||||
@ddt.data(
|
||||
('program_uuid', ''),
|
||||
('', 'text'),
|
||||
('', ''),
|
||||
)
|
||||
def test_missing_parameter(self, program_uuid, text):
|
||||
msg = u'You must provide both a program uuid and a comma separated list of external_student_key, username'
|
||||
response = self.client.post(self.url, data={
|
||||
@ddt.unpack
|
||||
def test_missing_parameter(self, program_uuid, text, mocked_render):
|
||||
error = (
|
||||
u"You must provide both a program uuid "
|
||||
u"and a series of lines with the format "
|
||||
u"'external_user_key,lms_username'."
|
||||
)
|
||||
self.client.post(self.url, data={
|
||||
'program_uuid': program_uuid,
|
||||
'text': text,
|
||||
})
|
||||
self._assert_props_list('errors', [msg], response)
|
||||
render_call_dict = mocked_render.call_args[0][1]
|
||||
assert render_call_dict['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('support.views.program_enrollments.link_program_enrollments_to_lms_users')
|
||||
@patch('support.views.program_enrollments.link_program_enrollments')
|
||||
def test_text(self, text, mocked_link):
|
||||
self.client.post(self.url, data={
|
||||
'program_uuid': self.program_uuid,
|
||||
@@ -508,18 +523,20 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
|
||||
})
|
||||
mocked_link.assert_called_once()
|
||||
mocked_link.assert_called_with(
|
||||
self.program_uuid,
|
||||
UUID(self.program_uuid),
|
||||
{
|
||||
'0001': 'learner-01',
|
||||
'0002': 'learner-02',
|
||||
}
|
||||
)
|
||||
|
||||
def test_junk_text(self):
|
||||
@patch_render
|
||||
def test_junk_text(self, mocked_render):
|
||||
text = 'alsdjflajsdflakjs'
|
||||
response = self.client.post(self.url, data={
|
||||
self.client.post(self.url, data={
|
||||
'program_uuid': self.program_uuid,
|
||||
'text': text,
|
||||
})
|
||||
msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(program_uuid=self.program_uuid, external_student_key=text)
|
||||
self._assert_props_list('errors', [msg], response)
|
||||
msg = u"All linking lines must be in the format 'external_user_key,lms_username'"
|
||||
render_call_dict = mocked_render.call_args[0][1]
|
||||
assert render_call_dict['errors'] == [msg]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""
|
||||
Support tool for changing course enrollments.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import csv
|
||||
from uuid import UUID
|
||||
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import View
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from lms.djangoapps.program_enrollments.api import link_program_enrollments
|
||||
from lms.djangoapps.support.decorators import require_support_permission
|
||||
|
||||
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
|
||||
|
||||
TEMPLATE_PATH = 'support/link_program_enrollments.html'
|
||||
|
||||
|
||||
@@ -44,25 +45,7 @@ class LinkProgramEnrollmentSupportView(View):
|
||||
"""
|
||||
program_uuid = request.POST.get('program_uuid', '').strip()
|
||||
text = request.POST.get('text', '')
|
||||
successes = []
|
||||
errors = []
|
||||
if not program_uuid or not text:
|
||||
error = 'You must provide both a program uuid and a comma separated list of external_student_key, username'
|
||||
errors = [error]
|
||||
else:
|
||||
reader = csv.DictReader(text.splitlines(), fieldnames=('external_key', 'username'))
|
||||
ext_key_to_lms_username = {
|
||||
(item['external_key'] or '').strip(): (item['username'] or '').strip()
|
||||
for item in reader
|
||||
}
|
||||
try:
|
||||
link_errors = link_program_enrollments_to_lms_users(program_uuid, ext_key_to_lms_username)
|
||||
except ValueError as e:
|
||||
errors = [str(e)]
|
||||
else:
|
||||
successes = [str(item) for item in ext_key_to_lms_username.items() if item not in link_errors]
|
||||
errors = [message for message in link_errors.values()]
|
||||
|
||||
successes, errors = self._validate_and_link(program_uuid, text)
|
||||
return render_to_response(
|
||||
TEMPLATE_PATH,
|
||||
{
|
||||
@@ -72,3 +55,46 @@ class LinkProgramEnrollmentSupportView(View):
|
||||
'text': text,
|
||||
}
|
||||
)
|
||||
|
||||
@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]
|
||||
"""
|
||||
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 [], [
|
||||
"Supplied program UUID '{}' is not a valid UUID.".format(program_uuid_string)
|
||||
]
|
||||
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 not in link_errors
|
||||
]
|
||||
errors = [message for message in link_errors.values()]
|
||||
return successes, errors
|
||||
|
||||
Reference in New Issue
Block a user