Revert "Create Python API for program_enrollments: Part IV"
This reverts commit af4e7a348a.
After merging the above commit, I was seeing 'internal-error's
on Stage in responses to the Registrar API. I am temporarily
reverting it until I can figure out a fix.
This commit is contained in:
committed by
Simon Chen
parent
e7a509faf0
commit
775d2fd807
@@ -1,39 +1,11 @@
|
||||
"""
|
||||
Python API exposed by the program_enrollments app to other in-process apps.
|
||||
Python API exposed by the proram_enrollments app to other in-process apps.
|
||||
|
||||
The functions are split into separate files for code organization, but they
|
||||
are imported into here so they can be imported directly from
|
||||
are wildcard-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 .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
|
||||
)
|
||||
from .linking import * # pylint: disable=wildcard-import
|
||||
from .reading import * # pylint: disable=wildcard-import
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
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,6 +8,7 @@ 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
|
||||
@@ -15,7 +16,6 @@ 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,6 +26,9 @@ 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}'
|
||||
@@ -33,7 +36,7 @@ EXISTING_USER_TEMPLATE = (
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames):
|
||||
"""
|
||||
Utility function to link ProgramEnrollments to LMS Users
|
||||
|
||||
@@ -41,9 +44,10 @@ def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
-program_uuid: the program for which we are linking program enrollments
|
||||
-external_keys_to_usernames: dict mapping `external_user_keys` to LMS usernames.
|
||||
|
||||
Returns: dict[str: str]
|
||||
Map from external keys to errors, for the external keys of users whose
|
||||
linking produced errors.
|
||||
Returns:
|
||||
{
|
||||
(external_key, username): Error message if there was an error
|
||||
}
|
||||
|
||||
Raises: ValueError if None is included in external_keys_to_usernames
|
||||
|
||||
@@ -56,7 +60,7 @@ def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
- 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. The input will be skipped. All other inputs will be processed and
|
||||
(external_key, username). The input will be skipped. All other inputs will be processed and
|
||||
enrollments updated, and then the function will return the dictionary of error messages.
|
||||
|
||||
If there is an error while enrolling a user in a waiting program course enrollment, the
|
||||
@@ -66,29 +70,34 @@ def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
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_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)
|
||||
users = _get_lms_users(external_keys_to_usernames.values())
|
||||
for item in external_keys_to_usernames.items():
|
||||
external_student_key, username = item
|
||||
|
||||
user = users.get(username)
|
||||
error_message = None
|
||||
if not user:
|
||||
error_message = NO_LMS_USER_TEMPLATE.format(username)
|
||||
elif not program_enrollment:
|
||||
|
||||
program_enrollment = program_enrollments.get(external_student_key)
|
||||
if 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)
|
||||
else:
|
||||
error_message = None
|
||||
error_message = user_already_linked_message(program_enrollment, user)
|
||||
|
||||
if error_message:
|
||||
logger.warning(error_message)
|
||||
errors[external_student_key] = error_message
|
||||
errors[item] = error_message
|
||||
continue
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
link_program_enrollment_to_lms_user(program_enrollment, user)
|
||||
@@ -101,11 +110,28 @@ def link_program_enrollments(program_uuid, external_keys_to_usernames):
|
||||
if str(e):
|
||||
error_message += ': '
|
||||
error_message += str(e)
|
||||
errors[external_student_key] = error_message
|
||||
errors[item] = error_message
|
||||
return errors
|
||||
|
||||
|
||||
def _user_already_linked_message(program_enrollment, user):
|
||||
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):
|
||||
"""
|
||||
Creates an error message that the specified program enrollment is already linked to an lms user
|
||||
"""
|
||||
@@ -118,6 +144,12 @@ 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
|
||||
@@ -145,37 +177,35 @@ def _get_lms_users(lms_usernames):
|
||||
}
|
||||
|
||||
|
||||
def link_program_enrollment_to_lms_user(program_enrollment, user):
|
||||
def _link_program_enrollment(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
|
||||
Links program enrollment to user.
|
||||
|
||||
Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
|
||||
program course enrollment
|
||||
IntegrityError if we try to create invalid records.
|
||||
Raises IntegrityError if ProgramEnrollment is invalid
|
||||
"""
|
||||
link_log_info = 'user id={} with external_user_key={} for program uuid={}'.format(
|
||||
user.id,
|
||||
logger.info('Linking external student key {} and user {}'.format(
|
||||
program_enrollment.external_user_key,
|
||||
program_enrollment.program_uuid,
|
||||
)
|
||||
logger.info("Linking " + link_log_info)
|
||||
user.username
|
||||
))
|
||||
program_enrollment.user = user
|
||||
program_enrollment.save()
|
||||
|
||||
|
||||
def _link_course_enrollments(program_enrollment, user):
|
||||
"""
|
||||
Enrolls user in waiting program course enrollments
|
||||
|
||||
Raises:
|
||||
IntegrityError if a constraint is violated
|
||||
CourseEnrollmentException if there is an issue enrolling the user in a course
|
||||
"""
|
||||
try:
|
||||
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
|
||||
for program_course_enrollment in program_enrollment.program_course_enrollments.all():
|
||||
program_course_enrollment.enroll(user)
|
||||
except CourseEnrollmentException as e:
|
||||
logger.error(
|
||||
"CourseEnrollmentException while linking {}: {}".format(
|
||||
link_log_info, str(e)
|
||||
)
|
||||
error_message = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
|
||||
user=user.username,
|
||||
course=program_course_enrollment.course_key
|
||||
)
|
||||
raise
|
||||
logger.exception(error_message)
|
||||
raise type(e)(error_message)
|
||||
|
||||
@@ -6,19 +6,6 @@ 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 = (
|
||||
@@ -157,7 +144,6 @@ 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,
|
||||
@@ -175,7 +161,6 @@ 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)
|
||||
@@ -200,7 +185,6 @@ 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"
|
||||
@@ -335,106 +319,3 @@ 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-')
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
(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,10 +15,11 @@ 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,
|
||||
_user_already_linked_message,
|
||||
link_program_enrollments
|
||||
link_program_enrollments_to_lms_users,
|
||||
user_already_linked_message
|
||||
)
|
||||
|
||||
LOG_PATH = 'lms.djangoapps.program_enrollments.api.linking'
|
||||
@@ -122,6 +123,15 @@ 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 """
|
||||
@@ -139,7 +149,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(self.program, {'0001': self.user_1.username})
|
||||
link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
|
||||
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_user_enrolled_in_program_courses(
|
||||
@@ -165,7 +175,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
|
||||
status='inactive'
|
||||
)
|
||||
|
||||
link_program_enrollments(self.program, {'0001': self.user_1.username})
|
||||
link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
|
||||
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
|
||||
@@ -199,7 +209,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(
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
@@ -212,7 +222,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
)
|
||||
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
|
||||
|
||||
self.assertDictEqual(errors, {'0002': expected_error_msg})
|
||||
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_no_program_enrollment(self.user_2, self.program)
|
||||
|
||||
@@ -221,7 +231,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
enrollment_2 = self._create_waiting_enrollment(self.program, '0002')
|
||||
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments(
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
@@ -231,7 +241,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': expected_error_msg})
|
||||
self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_no_user(enrollment_2)
|
||||
|
||||
@@ -246,17 +256,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(
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
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': expected_error_msg})
|
||||
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_program_enrollment(self.user_2, self.program, '0002')
|
||||
|
||||
@@ -273,17 +283,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
self._assert_program_enrollment(user_3, self.program, '0003', refresh=False)
|
||||
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments(
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
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': expected_error_msg})
|
||||
self.assertDictEqual(errors, {('0003', self.user_2.username): expected_error_msg})
|
||||
self._assert_program_enrollment(self.user_1, self.program, '0001')
|
||||
self._assert_no_program_enrollment(self.user_2, self.program)
|
||||
self._assert_program_enrollment(user_3, self.program, '0003')
|
||||
@@ -303,14 +313,22 @@ 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)
|
||||
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username
|
||||
}
|
||||
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}
|
||||
)
|
||||
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()
|
||||
@@ -330,15 +348,46 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
|
||||
program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001')
|
||||
self._create_waiting_enrollment(self.program, '0002')
|
||||
|
||||
errors = link_program_enrollments(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username,
|
||||
}
|
||||
)
|
||||
msg = 'Integrity error while linking program enrollments'
|
||||
with LogCapture() as logger:
|
||||
errors = link_program_enrollments_to_lms_users(
|
||||
self.program,
|
||||
{
|
||||
'0001': self.user_1.username,
|
||||
'0002': self.user_2.username,
|
||||
}
|
||||
)
|
||||
logger.check_present((LOG_PATH, 'ERROR', msg))
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn('UNIQUE constraint failed', errors['0001'])
|
||||
self.assertIn('UNIQUE constraint failed', errors[('0001', self.user_1.username)])
|
||||
self._assert_no_user(program_enrollment_1)
|
||||
self._assert_program_enrollment(self.user_2, self.program, '0002')
|
||||
|
||||
def test_invalid_uuid(self):
|
||||
self._create_waiting_enrollment(self.program, 'learner-0')
|
||||
with self.assertRaisesMessage(ValueError, 'badly formed hexadecimal UUID string'):
|
||||
link_program_enrollments_to_lms_users(
|
||||
'notauuid::thisisntauuid',
|
||||
{
|
||||
'learner-0': self.user_1.username,
|
||||
}
|
||||
)
|
||||
|
||||
def test_None(self):
|
||||
self._create_waiting_enrollment(self.program, 'learner-0')
|
||||
msg = 'external_user_key or username cannot be None'
|
||||
with self.assertRaisesMessage(ValueError, msg):
|
||||
link_program_enrollments_to_lms_users(
|
||||
self.program,
|
||||
{
|
||||
None: self.user_1.username,
|
||||
}
|
||||
)
|
||||
with self.assertRaisesMessage(ValueError, msg):
|
||||
link_program_enrollments_to_lms_users(
|
||||
'notauuid::thisisntauuid',
|
||||
{
|
||||
'learner-0': None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for program enrollment reading Python API.
|
||||
Tests for account linking Python API.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
@@ -7,30 +7,16 @@ 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,
|
||||
@@ -38,8 +24,7 @@ from ..reading import (
|
||||
fetch_program_enrollments,
|
||||
fetch_program_enrollments_by_student,
|
||||
get_program_course_enrollment,
|
||||
get_program_enrollment,
|
||||
get_users_by_external_keys
|
||||
get_program_enrollment
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -440,136 +425,3 @@ 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, [])
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
(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
|
||||
@@ -1,426 +0,0 @@
|
||||
"""
|
||||
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,76 +40,3 @@ 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__
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
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
|
||||
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
|
||||
|
||||
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,17 +70,11 @@ 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(
|
||||
parsed_program_uuid, ext_keys_to_usernames
|
||||
)
|
||||
link_program_enrollments_to_lms_users(program_uuid, ext_keys_to_usernames)
|
||||
except Exception as e:
|
||||
raise CommandError(str(e))
|
||||
raise CommandError(e)
|
||||
|
||||
def parse_user_items(self, user_items):
|
||||
"""
|
||||
@@ -88,21 +82,18 @@ 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)
|
||||
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)
|
||||
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,8 +3,6 @@ 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
|
||||
@@ -17,13 +15,13 @@ _COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_pro
|
||||
|
||||
class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
"""
|
||||
Test that the command calls link_program_enrollments
|
||||
Test that the command calls link_program_enrollments_to_lms_users
|
||||
correctly and handles exceptional input correctly.
|
||||
"""
|
||||
|
||||
program_uuid = 'a32c5da8-fb89-4f1e-97a7-b13de9e6dfa2'
|
||||
|
||||
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments"
|
||||
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments_to_lms_users"
|
||||
|
||||
@mock.patch(_LINKING_FUNCTION_MOCK_PATH, autospec=True)
|
||||
def test_good_input_calls_linking(self, mock_link):
|
||||
@@ -31,7 +29,7 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
|
||||
Command(), self.program_uuid, 'learner-01:user-01', 'learner-02:user-02'
|
||||
)
|
||||
mock_link.assert_called_once_with(
|
||||
UUID(self.program_uuid),
|
||||
self.program_uuid,
|
||||
{
|
||||
'learner-01': 'user-01',
|
||||
'learner-02': 'user-02',
|
||||
@@ -47,24 +45,6 @@ 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,
|
||||
@@ -73,10 +53,3 @@ 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,6 +4,8 @@ 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
|
||||
@@ -11,11 +13,16 @@ 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 student.models import CourseEnrollment
|
||||
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, ProgramEnrollmentStatuses
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unicode
|
||||
"""
|
||||
@@ -75,6 +82,17 @@ 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)
|
||||
|
||||
@@ -118,3 +136,89 @@ 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,6 +3,8 @@ 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-]+)'
|
||||
|
||||
@@ -17,6 +19,79 @@ 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,6 +6,7 @@ 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
|
||||
@@ -44,32 +45,6 @@ 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.
|
||||
@@ -92,16 +67,40 @@ 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, 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)
|
||||
student_key = serializers.CharField(allow_blank=False)
|
||||
status = serializers.ChoiceField(
|
||||
allow_blank=False,
|
||||
choices=ProgramCourseEnrollmentStatuses.__ALL__,
|
||||
)
|
||||
|
||||
|
||||
class ProgramCourseGradeSerializer(serializers.Serializer):
|
||||
@@ -111,7 +110,7 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
|
||||
Meant to be used with BaseProgramCourseGrade.
|
||||
"""
|
||||
# Required
|
||||
student_key = serializers.SerializerMethodField()
|
||||
student_key = serializers.CharField()
|
||||
|
||||
# From ProgramCourseGradeOk only
|
||||
passed = serializers.BooleanField(required=False)
|
||||
@@ -121,9 +120,6 @@ 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):
|
||||
"""
|
||||
@@ -162,3 +158,62 @@ 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,7 +4,6 @@ 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
|
||||
|
||||
@@ -29,12 +28,9 @@ 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,
|
||||
@@ -59,18 +55,11 @@ from ..constants import (
|
||||
REQUEST_STUDENT_KEY,
|
||||
CourseRunProgressStatuses
|
||||
)
|
||||
from ..constants import ProgramCourseResponseStatuses as CourseStatuses
|
||||
from ..constants import ProgramResponseStatuses as ProgramStatuses
|
||||
|
||||
_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),
|
||||
)
|
||||
_REST_API_MOCK_FMT = 'lms.djangoapps.program_enrollments.rest_api.{}'
|
||||
_VIEW_MOCK_FMT = _REST_API_MOCK_FMT.format('v1.views.{}')
|
||||
|
||||
|
||||
class ProgramCacheMixin(CacheIsolationMixin):
|
||||
@@ -97,9 +86,8 @@ class EnrollmentsDataMixin(ProgramCacheMixin):
|
||||
def setUpClass(cls):
|
||||
super(EnrollmentsDataMixin, cls).setUpClass()
|
||||
cls.start_cache_isolation()
|
||||
cls.organization_key = "testorg"
|
||||
cls.organization_key = "orgkey"
|
||||
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')
|
||||
@@ -346,6 +334,7 @@ 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'
|
||||
|
||||
@@ -392,7 +381,8 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
|
||||
json.dumps([{'status': 'enrolled'}]),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
self.assertEqual(response.data, 'invalid enrollment record')
|
||||
|
||||
def test_program_unauthorized(self):
|
||||
student = UserFactory.create(password='password')
|
||||
@@ -424,17 +414,22 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
|
||||
|
||||
response = self.request(url, json.dumps(enrollments), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertEqual('invalid enrollment record', response.data)
|
||||
|
||||
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 _patch_get_users:
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
url = self.get_url()
|
||||
response = self.request(url, json.dumps(enrollments), content_type='application/json')
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertDictEqual(
|
||||
response.data,
|
||||
{'learner-01': 'enrolled'}
|
||||
@@ -447,6 +442,8 @@ 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'
|
||||
|
||||
@@ -473,10 +470,14 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
]
|
||||
|
||||
url = self.get_url(program_uuid=0)
|
||||
with _patch_get_users:
|
||||
with mock.patch(
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=None
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
for i in range(3):
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i])
|
||||
@@ -498,14 +499,12 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
user = User.objects.create_user('test_user', 'test@example.com', 'password')
|
||||
url = self.get_url()
|
||||
with mock.patch(
|
||||
_get_users_patch_path,
|
||||
autospec=True,
|
||||
return_value={'abc1': user},
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
return_value=user
|
||||
):
|
||||
response = self.client.post(
|
||||
url, json.dumps(post_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key='abc1')
|
||||
self.assertEqual(enrollment.external_user_key, 'abc1')
|
||||
self.assertEqual(enrollment.program_uuid, self.program_uuid)
|
||||
@@ -524,13 +523,13 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
|
||||
url = self.get_url()
|
||||
with mock.patch(
|
||||
_get_users_patch_path,
|
||||
autospec=True,
|
||||
side_effect=ProviderDoesNotExistException(None),
|
||||
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
|
||||
autospec=True,
|
||||
side_effect=ProviderDoesNotExistException()
|
||||
):
|
||||
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
for i in range(3):
|
||||
enrollment = ProgramEnrollment.objects.get(external_user_key='abc{}'.format(i))
|
||||
@@ -547,6 +546,7 @@ 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,11 +692,19 @@ 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(
|
||||
@@ -722,11 +730,8 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
)
|
||||
|
||||
url = self.get_url()
|
||||
with _patch_get_users:
|
||||
response = self.client.put(
|
||||
url, json.dumps(request_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertEqual(5, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
@@ -750,11 +755,8 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
|
||||
)
|
||||
|
||||
url = self.get_url()
|
||||
with _patch_get_users:
|
||||
response = self.client.put(
|
||||
url, json.dumps(request_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
|
||||
self.assertEqual(self.success_status, response.status_code)
|
||||
self.assertEqual(4, len(response.data))
|
||||
for response_status in response.data.values():
|
||||
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
|
||||
@@ -868,7 +870,6 @@ 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)
|
||||
@@ -884,6 +885,7 @@ 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'}],
|
||||
@@ -895,6 +897,7 @@ 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')
|
||||
@@ -1187,7 +1190,7 @@ class ProgramCourseEnrollmentsModifyMixin(ProgramCourseEnrollmentsMixin):
|
||||
self.assert_program_course_enrollment('learner-4', 'active', False)
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentsPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
|
||||
class ProgramCourseEnrollmentPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
|
||||
""" Tests for course enrollment PATCH """
|
||||
|
||||
def request(self, path, data, **kwargs):
|
||||
@@ -1227,6 +1230,19 @@ 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)
|
||||
@@ -1245,32 +1261,20 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
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):
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_200_grades_with_no_exceptions(self, mock_course_grade_factory):
|
||||
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_grades_by_user = {
|
||||
self.student: (
|
||||
self.mock_grade(),
|
||||
None
|
||||
),
|
||||
other_student: (
|
||||
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
|
||||
None
|
||||
),
|
||||
}
|
||||
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
|
||||
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1288,21 +1292,20 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
]
|
||||
self.assertEqual(response.data['results'], expected_results)
|
||||
|
||||
def test_207_grades_with_some_exceptions(self):
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_207_grades_with_some_exceptions(self, mock_course_grade_factory):
|
||||
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_grades_by_user = {
|
||||
self.student: (None, Exception('Bad Data')),
|
||||
other_student: (
|
||||
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
|
||||
None,
|
||||
),
|
||||
}
|
||||
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
|
||||
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1318,18 +1321,20 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
]
|
||||
self.assertEqual(response.data['results'], expected_results)
|
||||
|
||||
def test_422_grades_with_only_exceptions(self):
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
|
||||
def test_422_grades_with_only_exceptions(self, mock_course_grade_factory):
|
||||
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_grades_by_user = {
|
||||
self.student: (None, Exception('Bad Data')),
|
||||
other_student: (None, Exception('Timeout')),
|
||||
}
|
||||
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
|
||||
|
||||
self.log_in_staff()
|
||||
url = self.get_url(course_id=self.course_id)
|
||||
with self.patch_grades_with(mock_grades_by_user):
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
expected_results = [
|
||||
{
|
||||
@@ -1343,26 +1348,6 @@ 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):
|
||||
@@ -1403,7 +1388,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_PATCH_FORMAT.format('get_programs_by_type'),
|
||||
_VIEW_MOCK_FMT.format('get_programs_by_type'),
|
||||
autospec=True,
|
||||
return_value=mock_return_value
|
||||
) as mock_get_programs_by_type:
|
||||
@@ -1417,7 +1402,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.course_staff.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
autospec=True,
|
||||
return_value=[self.mock_program_data[0]]
|
||||
) as mock_get_programs:
|
||||
@@ -1436,7 +1421,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.course_staff.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
autospec=True,
|
||||
side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
|
||||
) as mock_get_programs:
|
||||
@@ -1449,7 +1434,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
mock.call(course=other_course_key),
|
||||
], any_order=True)
|
||||
|
||||
@mock.patch(_VIEW_PATCH_FORMAT.format('get_programs'), autospec=True, return_value=None)
|
||||
@mock.patch(_VIEW_MOCK_FMT.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))
|
||||
@@ -1470,7 +1455,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
|
||||
with mock.patch(
|
||||
_VIEW_PATCH_FORMAT.format('get_programs'),
|
||||
_VIEW_MOCK_FMT.format('get_programs'),
|
||||
autospec=True,
|
||||
return_value=self.mock_program_data
|
||||
) as mock_get_programs:
|
||||
@@ -1490,11 +1475,6 @@ 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()
|
||||
@@ -1634,14 +1614,16 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
expected_course_run_ids.add(text_type(other_course_key))
|
||||
self.assertEqual(expected_course_run_ids, actual_course_run_ids)
|
||||
|
||||
@patch_resume_url
|
||||
_GET_RESUME_URL = _VIEW_MOCK_FMT.format('get_resume_urls_for_enrollments')
|
||||
|
||||
@mock.patch(_GET_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])
|
||||
|
||||
@patch_resume_url
|
||||
@mock.patch(_GET_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'
|
||||
@@ -1651,7 +1633,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
self.assertTrue(response_resume_url.startswith("http://testserver"))
|
||||
self.assertTrue(response_resume_url.endswith(resume_url))
|
||||
|
||||
@patch_resume_url
|
||||
@mock.patch(_GET_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/'
|
||||
@@ -1735,7 +1717,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
|
||||
display_name='unit_1'
|
||||
)
|
||||
|
||||
mock_path = _REST_API_PATCH_FORMAT.format('utils.get_dates_for_course')
|
||||
mock_path = _REST_API_MOCK_FMT.format('v1.utils.get_dates_for_course')
|
||||
with mock.patch(mock_path) as mock_get_dates:
|
||||
mock_get_dates.return_value = {
|
||||
(section_1.location, 'due'): section_1.due,
|
||||
@@ -1986,10 +1968,6 @@ 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()
|
||||
@@ -2011,14 +1989,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
self.end_cache_isolation()
|
||||
super(EnrollmentDataResetViewTests, self).tearDown()
|
||||
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
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)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
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')
|
||||
@@ -2027,7 +2005,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
mock_call_command.assert_has_calls([])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
def test_reset(self, mock_call_command):
|
||||
programs = [str(uuid4()), str(uuid4())]
|
||||
self.set_org_in_catalog_cache(self.organization, programs)
|
||||
@@ -2040,7 +2018,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
def test_reset_without_idp(self, mock_call_command):
|
||||
organization = LMSOrganizationFactory()
|
||||
programs = [str(uuid4()), str(uuid4())]
|
||||
@@ -2053,14 +2031,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
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)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
def test_no_programs_doesnt_break(self, mock_call_command):
|
||||
programs = []
|
||||
self.set_org_in_catalog_cache(self.organization, programs)
|
||||
@@ -2072,7 +2050,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
|
||||
])
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
|
||||
@patch_call_command
|
||||
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
|
||||
def test_missing_body_content(self, mock_call_command):
|
||||
response = self.client.post(
|
||||
reverse('programs_api:v1:reset_enrollment_data'),
|
||||
|
||||
@@ -120,28 +120,6 @@ 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_due_dates(request, course_key, user):
|
||||
"""
|
||||
Get due date information for a user for blocks in a course.
|
||||
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -15,28 +17,27 @@ 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.certificates.api import get_certificate_for_user
|
||||
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,
|
||||
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,
|
||||
get_provider_slug,
|
||||
get_saml_provider_for_organization,
|
||||
iter_program_course_grades,
|
||||
write_program_course_enrollments,
|
||||
write_program_enrollments
|
||||
get_user_by_program_id
|
||||
)
|
||||
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,
|
||||
@@ -52,15 +53,22 @@ 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
|
||||
from .constants import (
|
||||
ENABLE_ENROLLMENT_RESET_FLAG,
|
||||
MAX_ENROLLMENT_RECORDS,
|
||||
ProgramCourseResponseStatuses,
|
||||
ProgramResponseStatuses
|
||||
)
|
||||
from .serializers import (
|
||||
CourseRunOverviewListSerializer,
|
||||
ProgramCourseEnrollmentRequestSerializer,
|
||||
ProgramCourseEnrollmentSerializer,
|
||||
ProgramCourseGradeError,
|
||||
ProgramCourseGradeOk,
|
||||
ProgramCourseGradeSerializer,
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
ProgramEnrollmentSerializer,
|
||||
ProgramEnrollmentUpdateRequestSerializer
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
ProgramEnrollmentSerializer
|
||||
)
|
||||
from .utils import (
|
||||
ProgramCourseSpecificViewMixin,
|
||||
@@ -70,74 +78,16 @@ from .utils import (
|
||||
get_course_run_url,
|
||||
get_due_dates,
|
||||
get_emails_enabled,
|
||||
get_enrollment_http_code,
|
||||
verify_course_exists_and_in_program,
|
||||
verify_program_exists
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgramEnrollmentsView(
|
||||
EnrollmentWriteMixin,
|
||||
DeveloperErrorViewMixin,
|
||||
ProgramSpecificViewMixin,
|
||||
ProgramCourseSpecificViewMixin,
|
||||
PaginatedAPIView,
|
||||
):
|
||||
"""
|
||||
@@ -231,7 +181,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
|
||||
* 200: OK - All students were successfully enrolled.
|
||||
* 201: CREATED - All students were successfully enrolled.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'enrolled',
|
||||
@@ -295,7 +245,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
|
||||
* 200: OK - All students were successfully enrolled.
|
||||
* 201: CREATED - All students were successfully enrolled.
|
||||
* Example json response:
|
||||
{
|
||||
'123': 'enrolled',
|
||||
@@ -325,65 +275,187 @@ 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(
|
||||
self.program_uuid
|
||||
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, program_uuid=None):
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create program enrollments for a list of learners
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_program_enrollment,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@verify_program_exists
|
||||
def patch(self, request, program_uuid=None): # pylint: disable=unused-argument
|
||||
def patch(self, request, **kwargs):
|
||||
"""
|
||||
Update program enrollments for a list of learners
|
||||
Modify program enrollments for a list of learners
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentModifyRequestSerializer,
|
||||
self.modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@verify_program_exists
|
||||
def put(self, request, program_uuid=None): # pylint: disable=unused-argument
|
||||
def put(self, request, **kwargs):
|
||||
"""
|
||||
Create/update program enrollments for a list of learners
|
||||
Create/modify program enrollments for a list of learners
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
kwargs['program_uuid'],
|
||||
ProgramEnrollmentCreateRequestSerializer,
|
||||
self.create_or_modify_program_enrollment,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def perform_enrollment_write(self, enrollment_requests, create, update):
|
||||
def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
|
||||
"""
|
||||
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.
|
||||
Validates the given enrollment record and checks that it isn't a duplicate
|
||||
"""
|
||||
return write_program_enrollments(
|
||||
self.program_uuid, enrollment_requests, create=create, update=update
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
class ProgramCourseEnrollmentsView(
|
||||
EnrollmentWriteMixin,
|
||||
DeveloperErrorViewMixin,
|
||||
ProgramCourseSpecificViewMixin,
|
||||
PaginatedAPIView,
|
||||
@@ -469,14 +541,6 @@ 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):
|
||||
"""
|
||||
@@ -496,7 +560,11 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Enroll a list of students in a course in a program
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.enroll_learner_in_course
|
||||
)
|
||||
|
||||
@verify_course_exists_and_in_program
|
||||
# pylint: disable=unused-argument
|
||||
@@ -504,7 +572,11 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Modify the program course enrollments of a list of learners
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.modify_learner_enrollment_status
|
||||
)
|
||||
|
||||
@verify_course_exists_and_in_program
|
||||
# pylint: disable=unused-argument
|
||||
@@ -512,29 +584,140 @@ class ProgramCourseEnrollmentsView(
|
||||
"""
|
||||
Create or Update the program course enrollments of a list of learners
|
||||
"""
|
||||
return self.handle_write_request()
|
||||
|
||||
def perform_enrollment_write(self, enrollment_requests, create, update):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
return write_program_course_enrollments(
|
||||
self.program_uuid,
|
||||
self.course_key,
|
||||
enrollment_requests,
|
||||
create=create,
|
||||
update=update,
|
||||
return self.create_or_modify_enrollments(
|
||||
request,
|
||||
program_uuid,
|
||||
self.create_or_update_learner_enrollment
|
||||
)
|
||||
|
||||
def create_or_modify_enrollments(self, request, program_uuid, operation):
|
||||
"""
|
||||
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_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,
|
||||
self.course_key,
|
||||
enrollment_request['status']
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -616,13 +799,83 @@ class ProgramCourseGradesView(
|
||||
"""
|
||||
Defines the GET list endpoint for ProgramCourseGrade objects.
|
||||
"""
|
||||
grade_results = list(iter_program_course_grades(
|
||||
self.program_uuid, self.course_key, self.paginate_queryset
|
||||
))
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
grade_results = self._load_grade_results(program_uuid, course_key)
|
||||
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):
|
||||
"""
|
||||
@@ -998,12 +1251,10 @@ class EnrollmentDataResetView(APIView):
|
||||
return Response('organization {} not found'.format(org_key), status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
provider = get_saml_provider_for_organization(organization)
|
||||
idp_slug = get_provider_slug(organization)
|
||||
call_command('remove_social_auth_users', idp_slug, force=True)
|
||||
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,9 +11,10 @@ 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, link_program_enrollment_to_lms_user
|
||||
from .api import fetch_program_enrollments_by_student
|
||||
from .models import ProgramEnrollment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -94,4 +95,17 @@ def matriculate_learner(user, uid):
|
||||
authorizing_org.short_name
|
||||
)
|
||||
continue
|
||||
link_program_enrollment_to_lms_user(enrollment, user)
|
||||
|
||||
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
|
||||
|
||||
@@ -6,15 +6,18 @@ 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
|
||||
|
||||
|
||||
@@ -170,3 +173,89 @@ 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,16 +341,27 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_exception_on_enrollment_failure(self):
|
||||
def test_log_on_enrollment_failure(self):
|
||||
program_enrollment = self._create_waiting_program_enrollment()
|
||||
self._create_waiting_course_enrollments(program_enrollment)
|
||||
program_course_enrollments = 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 pytest.raises(CourseEnrollmentException):
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.user,
|
||||
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
|
||||
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'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_log_on_unexpected_exception(self):
|
||||
@@ -360,7 +371,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.api.linking.enroll_in_masters_track') as enrollMock:
|
||||
with mock.patch('lms.djangoapps.program_enrollments.models.ProgramCourseEnrollment.enroll') as enrollMock:
|
||||
enrollMock.side_effect = Exception('unexpected error')
|
||||
with LogCapture(logger.name) as log:
|
||||
with self.assertRaisesRegex(Exception, 'unexpected error'):
|
||||
|
||||
157
lms/djangoapps/program_enrollments/tests/test_utils.py
Normal file
157
lms/djangoapps/program_enrollments/tests/test_utils.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
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'
|
||||
)
|
||||
117
lms/djangoapps/program_enrollments/utils.py
Normal file
117
lms/djangoapps/program_enrollments/utils.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
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,13 +9,12 @@ import itertools
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4, UUID
|
||||
from uuid import uuid4
|
||||
|
||||
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
|
||||
@@ -23,6 +22,7 @@ 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,12 +446,6 @@ 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()
|
||||
@@ -460,62 +454,53 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
|
||||
self.program_uuid = str(uuid4())
|
||||
self.text = '0001,user-0001\n0002,user-02'
|
||||
|
||||
@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(self, field_name, value, response):
|
||||
self.assertIn('"{}": "{}"'.format(field_name, value), six.text_type(response.content, encoding='utf-8'))
|
||||
|
||||
def test_rendering(self):
|
||||
def _assert_props_list(self, field_name, values, response):
|
||||
"""
|
||||
Test the view without mocking out the rendering like the rest of the tests.
|
||||
Assert that that page is being rendered with a specific list of props
|
||||
"""
|
||||
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)
|
||||
content = six.text_type(response.content, encoding='utf-8')
|
||||
assert '"programUUID": ""' in content
|
||||
assert '"text": ""' in content
|
||||
self._assert_props_list('successes', [], response)
|
||||
self._assert_props_list('errors', [], response)
|
||||
self._assert_props('programUUID', '', response)
|
||||
self._assert_props('text', '', response)
|
||||
|
||||
@patch_render
|
||||
def test_invalid_uuid(self, mocked_render):
|
||||
self.client.post(self.url, data={
|
||||
def test_invalid_uuid(self):
|
||||
response = self.client.post(self.url, data={
|
||||
'program_uuid': 'notauuid',
|
||||
'text': self.text,
|
||||
})
|
||||
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]
|
||||
self._assert_props_list('errors', [u'badly formed hexadecimal UUID string'], response)
|
||||
|
||||
@patch_render
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
('program_uuid', ''),
|
||||
('', 'text'),
|
||||
('', ''),
|
||||
)
|
||||
@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={
|
||||
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={
|
||||
'program_uuid': program_uuid,
|
||||
'text': text,
|
||||
})
|
||||
render_call_dict = mocked_render.call_args[0][1]
|
||||
assert render_call_dict['errors'] == [error]
|
||||
self._assert_props_list('errors', [msg], response)
|
||||
|
||||
@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')
|
||||
@patch('support.views.program_enrollments.link_program_enrollments_to_lms_users')
|
||||
def test_text(self, text, mocked_link):
|
||||
self.client.post(self.url, data={
|
||||
'program_uuid': self.program_uuid,
|
||||
@@ -523,20 +508,18 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
|
||||
})
|
||||
mocked_link.assert_called_once()
|
||||
mocked_link.assert_called_with(
|
||||
UUID(self.program_uuid),
|
||||
self.program_uuid,
|
||||
{
|
||||
'0001': 'learner-01',
|
||||
'0002': 'learner-02',
|
||||
}
|
||||
)
|
||||
|
||||
@patch_render
|
||||
def test_junk_text(self, mocked_render):
|
||||
def test_junk_text(self):
|
||||
text = 'alsdjflajsdflakjs'
|
||||
self.client.post(self.url, data={
|
||||
response = self.client.post(self.url, data={
|
||||
'program_uuid': self.program_uuid,
|
||||
'text': text,
|
||||
})
|
||||
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]
|
||||
msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(program_uuid=self.program_uuid, external_student_key=text)
|
||||
self._assert_props_list('errors', [msg], response)
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Support tool for changing course enrollments.
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
from __future__ import absolute_import
|
||||
|
||||
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'
|
||||
|
||||
|
||||
@@ -45,7 +44,25 @@ class LinkProgramEnrollmentSupportView(View):
|
||||
"""
|
||||
program_uuid = request.POST.get('program_uuid', '').strip()
|
||||
text = request.POST.get('text', '')
|
||||
successes, errors = self._validate_and_link(program_uuid, text)
|
||||
successes = []
|
||||
errors = []
|
||||
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()]
|
||||
|
||||
return render_to_response(
|
||||
TEMPLATE_PATH,
|
||||
{
|
||||
@@ -55,46 +72,3 @@ 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