Revert "Revert "Create Python API for program_enrollments: Part IV"" (#21759)

This reverts commit a67b9f70a16a0f16a842aad84754b245a2480b5f,
reinstating commit cf78660ed35712f9bb7c112f70411179070d7382.
The original commit was reverted because I thought I found
bugs in it while verifying it on Stage, but it turns out that
it was simply misconfigured Stage data that causing errors.

The original commit's message has has been copied below:

This commit completes the program_enrollments LMS app
Python API for the time being. It does the following:
* Add bulk-lookup of users by external key in api/reading.py
* Add bulk-writing of program enrollments in api/writing.py
* Move grade-reading to api/grades.py
* Refactor api/linking.py to use api/writing.py
* Refactor signals.py to use api/linking.py
* Update rest_api/v1/views.py to utilize all these changes
* Update linking management command and support tool to use API
* Remove outdated tests from test_models.py
* Misc. cleanup

EDUCATOR-4321
This commit is contained in:
Kyle McCormick
2019-09-24 10:49:54 -04:00
committed by GitHub
parent 76d7270fe9
commit da08357d89
27 changed files with 1581 additions and 1393 deletions

View File

@@ -1,11 +1,39 @@
"""
Python API exposed by the proram_enrollments app to other in-process apps.
Python API exposed by the program_enrollments app to other in-process apps.
The functions are split into separate files for code organization, but they
are wildcard-imported into here so they can be imported directly from
are imported into here so they can be imported directly from
`lms.djangoapps.program_enrollments.api`.
When adding new functions to this API, add them to the appropriate module
within the /api/ folder, and then "expose" them here by importing them.
We use explicit imports here because (1) it hides internal variables in the
sub-modules and (2) it provides a nice catalog of functions for someone
using this API.
"""
from __future__ import absolute_import
from .linking import * # pylint: disable=wildcard-import
from .reading import * # pylint: disable=wildcard-import
from .grades import iter_program_course_grades
from .linking import link_program_enrollment_to_lms_user, link_program_enrollments
from .reading import (
fetch_program_course_enrollments,
fetch_program_course_enrollments_by_student,
fetch_program_enrollments,
fetch_program_enrollments_by_student,
get_program_course_enrollment,
get_program_enrollment,
get_provider_slug,
get_saml_provider_for_organization,
get_saml_provider_for_program,
get_users_by_external_keys
)
from .writing import (
change_program_course_enrollment_status,
change_program_enrollment_status,
create_program_course_enrollment,
create_program_enrollment,
enroll_in_masters_track,
write_program_course_enrollments,
write_program_enrollments
)

View File

@@ -10,9 +10,9 @@ away in https://openedx.atlassian.net/browse/ENT-2294
"""
from __future__ import absolute_import, unicode_literals
from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
from lms.djangoapps.course_api.api import get_course_run_url as get_course_run_url_util
from lms.djangoapps.course_api.api import get_due_dates as get_due_dates_util
from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
def get_due_dates(request, course_key, user):

View File

@@ -0,0 +1,135 @@
"""
Python API functions related to reading program-course grades.
Outside of this subpackage, import these functions
from `lms.djangoapps.program_enrollments.api`.
"""
from __future__ import absolute_import, unicode_literals
import logging
from six import text_type
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
from util.query import read_replica_or_default
from .reading import fetch_program_course_enrollments
logger = logging.getLogger(__name__)
def iter_program_course_grades(program_uuid, course_key, paginate_queryset_fn=None):
"""
Load grades (or grading errors) for a given program-course.
Arguments:
program_uuid (str)
course_key (CourseKey)
paginate_queryset_fn (QuerySet -> QuerySet):
Optional function to paginate the results,
generally passed in from `self.request.paginate_queryset`
on a paginated DRF `APIView`.
If `None`, all results will be loaded and returned.
Returns: generator[BaseProgramCourseGrade]
"""
enrollments_qs = fetch_program_course_enrollments(
program_uuid=program_uuid,
course_key=course_key,
realized_only=True,
).select_related(
'program_enrollment',
'program_enrollment__user',
).using(read_replica_or_default())
enrollments = (
paginate_queryset_fn(enrollments_qs) if paginate_queryset_fn
else enrollments_qs
)
if not enrollments:
return []
return _generate_grades(course_key, list(enrollments))
def _generate_grades(course_key, enrollments):
"""
Load enrolled user grades for a program-course,
using bulk fetching for efficiency.
Arguments:
course_key (CourseKey)
enrollments (list[ProgramCourseEnrollment])
Yields: BaseProgramCourseGrade
"""
users = [enrollment.program_enrollment.user for enrollment in enrollments]
prefetch_course_grades(course_key, users)
try:
grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
for enrollment, grade_tuple in zip(enrollments, grades_iter):
user, course_grade, exception = grade_tuple
if course_grade:
yield ProgramCourseGradeOk(enrollment, course_grade)
else:
error_template = 'Failed to load course grade for user ID {} in {}: {}'
error_string = error_template.format(
user.id,
course_key,
text_type(exception) if exception else 'Unknown error'
)
logger.error(error_string)
yield ProgramCourseGradeError(enrollment, exception)
finally:
clear_prefetched_course_grades(course_key)
class BaseProgramCourseGrade(object):
"""
Base for either a courserun grade or grade-loading failure.
Can be passed to ProgramCourseGradeResultSerializer.
"""
is_error = None # Override in subclass
def __init__(self, program_course_enrollment):
"""
Given a ProgramCourseEnrollment,
create a BaseProgramCourseGrade instance.
"""
self.program_course_enrollment = program_course_enrollment
class ProgramCourseGradeOk(BaseProgramCourseGrade):
"""
Represents a courserun grade for a user enrolled through a program.
"""
is_error = False
def __init__(self, program_course_enrollment, course_grade):
"""
Given a ProgramCourseEnrollment and course grade object,
create a ProgramCourseGradeOk.
"""
super(ProgramCourseGradeOk, self).__init__(
program_course_enrollment
)
self.passed = course_grade.passed
self.percent = course_grade.percent
self.letter_grade = course_grade.letter_grade
class ProgramCourseGradeError(BaseProgramCourseGrade):
"""
Represents a failure to load a courserun grade for a user enrolled through
a program.
"""
is_error = True
def __init__(self, program_course_enrollment, exception=None):
"""
Given a ProgramCourseEnrollment and an Exception,
create a ProgramCourseGradeError.
"""
super(ProgramCourseGradeError, self).__init__(
program_course_enrollment
)
self.error = text_type(exception) if exception else "Unknown error"

View File

@@ -8,7 +8,6 @@ from `lms.djangoapps.program_enrollments.api`.
from __future__ import absolute_import, unicode_literals
import logging
from uuid import UUID
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
@@ -16,6 +15,7 @@ from django.db import IntegrityError, transaction
from student.models import CourseEnrollmentException
from .reading import fetch_program_enrollments
from .writing import enroll_in_masters_track
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -26,9 +26,6 @@ NO_PROGRAM_ENROLLMENT_TEMPLATE = (
'key={external_student_key}'
)
NO_LMS_USER_TEMPLATE = 'No user found with username {}'
COURSE_ENROLLMENT_ERR_TEMPLATE = (
'Failed to enroll user {user} with waiting program course enrollment for course {course}'
)
EXISTING_USER_TEMPLATE = (
'Program enrollment with external_student_key={external_student_key} is already linked to '
'{account_relation} account username={username}'
@@ -36,7 +33,7 @@ EXISTING_USER_TEMPLATE = (
@transaction.atomic
def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames):
def link_program_enrollments(program_uuid, external_keys_to_usernames):
"""
Utility function to link ProgramEnrollments to LMS Users
@@ -44,10 +41,9 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
-program_uuid: the program for which we are linking program enrollments
-external_keys_to_usernames: dict mapping `external_user_keys` to LMS usernames.
Returns:
{
(external_key, username): Error message if there was an error
}
Returns: dict[str: str]
Map from external keys to errors, for the external keys of users whose
linking produced errors.
Raises: ValueError if None is included in external_keys_to_usernames
@@ -60,7 +56,7 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
- No enrollment is found for the given program and external_user_key
- The enrollment already has a user
An error message will be logged, and added to a dictionary of error messages keyed by
(external_key, username). The input will be skipped. All other inputs will be processed and
external_key. The input will be skipped. All other inputs will be processed and
enrollments updated, and then the function will return the dictionary of error messages.
If there is an error while enrolling a user in a waiting program course enrollment, the
@@ -70,34 +66,29 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
user but still have waiting course enrollments. All other inputs will be processed
normally.
"""
_validate_inputs(program_uuid, external_keys_to_usernames)
errors = {}
program_enrollments = _get_program_enrollments_by_ext_key(
program_uuid, external_keys_to_usernames.keys()
)
users = _get_lms_users(external_keys_to_usernames.values())
for item in external_keys_to_usernames.items():
external_student_key, username = item
user = users.get(username)
error_message = None
users_by_username = _get_lms_users(external_keys_to_usernames.values())
for external_student_key, username in external_keys_to_usernames.items():
program_enrollment = program_enrollments.get(external_student_key)
user = users_by_username.get(username)
if not user:
error_message = NO_LMS_USER_TEMPLATE.format(username)
program_enrollment = program_enrollments.get(external_student_key)
if not program_enrollment:
elif not program_enrollment:
error_message = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(
program_uuid=program_uuid,
external_student_key=external_student_key
)
elif program_enrollment.user:
error_message = user_already_linked_message(program_enrollment, user)
error_message = _user_already_linked_message(program_enrollment, user)
else:
error_message = None
if error_message:
logger.warning(error_message)
errors[item] = error_message
errors[external_student_key] = error_message
continue
try:
with transaction.atomic():
link_program_enrollment_to_lms_user(program_enrollment, user)
@@ -110,28 +101,11 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
if str(e):
error_message += ': '
error_message += str(e)
errors[item] = error_message
errors[external_student_key] = error_message
return errors
def link_program_enrollment_to_lms_user(program_enrollment, user):
"""
Attempts to link the given program enrollment to the given user
If the enrollment has any program course enrollments, enroll the user in those courses as well
Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
program course enrollment
IntegrityError if we try to create invalid records.
"""
try:
_link_program_enrollment(program_enrollment, user)
_link_course_enrollments(program_enrollment, user)
except IntegrityError:
logger.exception("Integrity error while linking program enrollments")
raise
def user_already_linked_message(program_enrollment, user):
def _user_already_linked_message(program_enrollment, user):
"""
Creates an error message that the specified program enrollment is already linked to an lms user
"""
@@ -144,12 +118,6 @@ def user_already_linked_message(program_enrollment, user):
)
def _validate_inputs(program_uuid, external_keys_to_usernames):
if None in external_keys_to_usernames or None in external_keys_to_usernames.values():
raise ValueError('external_user_key or username cannot be None')
UUID(str(program_uuid)) # raises ValueError if invalid
def _get_program_enrollments_by_ext_key(program_uuid, external_student_keys):
"""
Does a bulk read of ProgramEnrollments for a given program and list of external student keys
@@ -177,35 +145,37 @@ def _get_lms_users(lms_usernames):
}
def _link_program_enrollment(program_enrollment, user):
def link_program_enrollment_to_lms_user(program_enrollment, user):
"""
Links program enrollment to user.
Attempts to link the given program enrollment to the given user
If the enrollment has any program course enrollments, enroll the user in those courses as well
Raises IntegrityError if ProgramEnrollment is invalid
Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
program course enrollment
IntegrityError if we try to create invalid records.
"""
logger.info('Linking external student key {} and user {}'.format(
link_log_info = 'user id={} with external_user_key={} for program uuid={}'.format(
user.id,
program_enrollment.external_user_key,
user.username
))
program_enrollment.program_uuid,
)
logger.info("Linking " + link_log_info)
program_enrollment.user = user
program_enrollment.save()
def _link_course_enrollments(program_enrollment, user):
"""
Enrolls user in waiting program course enrollments
Raises:
IntegrityError if a constraint is violated
CourseEnrollmentException if there is an issue enrolling the user in a course
"""
try:
for program_course_enrollment in program_enrollment.program_course_enrollments.all():
program_course_enrollment.enroll(user)
program_enrollment.save()
program_course_enrollments = program_enrollment.program_course_enrollments.all()
for pce in program_course_enrollments:
pce.course_enrollment = enroll_in_masters_track(
user, pce.course_key, pce.status
)
pce.save()
except IntegrityError:
logger.error("Integrity error while linking " + link_log_info)
raise
except CourseEnrollmentException as e:
error_message = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
user=user.username,
course=program_course_enrollment.course_key
logger.error(
"CourseEnrollmentException while linking {}: {}".format(
link_log_info, str(e)
)
)
logger.exception(error_message)
raise type(e)(error_message)
raise

View File

@@ -6,6 +6,19 @@ from `lms.djangoapps.program_enrollments.api`.
"""
from __future__ import absolute_import, unicode_literals
from organizations.models import Organization
from social_django.models import UserSocialAuth
from openedx.core.djangoapps.catalog.utils import get_programs
from third_party_auth.models import SAMLProviderConfig
from ..exceptions import (
BadOrganizationShortNameException,
ProgramDoesNotExistException,
ProgramHasNoAuthoringOrganizationException,
ProviderConfigurationException,
ProviderDoesNotExistException
)
from ..models import ProgramCourseEnrollment, ProgramEnrollment
_STUDENT_ARG_ERROR_MESSAGE = (
@@ -144,6 +157,7 @@ def fetch_program_course_enrollments(
users=None,
external_user_keys=None,
program_enrollment_statuses=None,
program_enrollments=None,
active_only=False,
inactive_only=False,
realized_only=False,
@@ -161,6 +175,7 @@ def fetch_program_course_enrollments(
* users (iterable[User])
* external_user_keys (iterable[str])
* program_enrollment_statuses (iterable[str])
* program_enrollments (iterable[ProgramEnrollment])
* active_only (bool)
* inactive_only (bool)
* realized_only (bool)
@@ -185,6 +200,7 @@ def fetch_program_course_enrollments(
"program_enrollment__user__in": users,
"program_enrollment__external_user_key__in": external_user_keys,
"program_enrollment__status__in": program_enrollment_statuses,
"program_enrollment__in": program_enrollments,
}
if active_only:
filters["status"] = "active"
@@ -319,3 +335,106 @@ def _remove_none_values(dictionary):
return {
key: value for key, value in dictionary.items() if value is not None
}
def get_users_by_external_keys(program_uuid, external_user_keys):
"""
Given a program and a set of external keys,
return a dict from external user keys to Users.
Args:
program_uuid (UUID|str):
uuid for program these users is/will be enrolled in
external_user_keys (sequence[str]):
external user keys used by the program creator's IdP.
Returns: dict[str: User|None]
A dict mapping external user keys to Users.
If an external user key is not registered, then None is returned instead
of a User for that key.
Raises:
ProgramDoesNotExistException
ProgramHasNoAuthoringOrganizationException
BadOrganizationShortNameException
ProviderDoesNotExistsException
ProviderConfigurationException
"""
saml_provider = get_saml_provider_for_program(program_uuid)
social_auth_uids = {
saml_provider.get_social_auth_uid(external_user_key)
for external_user_key in external_user_keys
}
social_auths = UserSocialAuth.objects.filter(uid__in=social_auth_uids)
found_users_by_external_keys = {
saml_provider.get_remote_id_from_social_auth(social_auth): social_auth.user
for social_auth in social_auths
}
# Default all external keys to None, because external keys
# without a User will not appear in `found_users_by_external_keys`.
users_by_external_keys = {key: None for key in external_user_keys}
users_by_external_keys.update(found_users_by_external_keys)
return users_by_external_keys
def get_saml_provider_for_program(program_uuid):
"""
Return currently configured SAML provider for the Organization
administering the given program.
Arguments:
program_uuid (UUID|str)
Returns: SAMLProvider
Raises:
ProgramDoesNotExistException
ProgramHasNoAuthoringOrganizationException
BadOrganizationShortNameException
"""
program = get_programs(uuid=program_uuid)
if program is None:
raise ProgramDoesNotExistException(program_uuid)
authoring_orgs = program.get('authoring_organizations')
org_key = authoring_orgs[0].get('key') if authoring_orgs else None
if not org_key:
raise ProgramHasNoAuthoringOrganizationException(program_uuid)
try:
organization = Organization.objects.get(short_name=org_key)
except Organization.DoesNotExist:
raise BadOrganizationShortNameException(org_key)
return get_saml_provider_for_organization(organization)
def get_saml_provider_for_organization(organization):
"""
Return currently configured SAML provider for the given Organization.
Arguments:
organization: Organization
Returns: SAMLProvider
Raises:
ProviderDoesNotExistsException
ProviderConfigurationException
"""
try:
provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
except SAMLProviderConfig.DoesNotExist:
raise ProviderDoesNotExistException(organization)
except SAMLProviderConfig.MultipleObjectsReturned:
raise ProviderConfigurationException(organization)
return provider_config
def get_provider_slug(provider_config):
"""
Returns slug identifying a SAML provider.
Arguments:
provider_config: SAMLProvider
Returns: str
"""
return provider_config.provider_id.strip('saml-')

View File

@@ -0,0 +1,12 @@
"""
(Future home of) Tests for program_enrollments grade-reading Python API.
Currently, we do not directly unit test `load_program_course_grades`.
This is okay for now because it is used in
`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
Eventually it would be good to directly test the Python API function and just use
mocks in the view tests.
This file serves as a placeholder and reminder to do that the next time there
is development on the program_enrollments grades API.
"""
from __future__ import absolute_import, unicode_literals

View File

@@ -15,11 +15,10 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
from student.tests.factories import UserFactory
from ..linking import (
COURSE_ENROLLMENT_ERR_TEMPLATE,
NO_LMS_USER_TEMPLATE,
NO_PROGRAM_ENROLLMENT_TEMPLATE,
link_program_enrollments_to_lms_users,
user_already_linked_message
_user_already_linked_message,
link_program_enrollments
)
LOG_PATH = 'lms.djangoapps.program_enrollments.api.linking'
@@ -123,15 +122,6 @@ class TestLinkProgramEnrollmentsMixin(object):
[course_enrollment.course.id for course_enrollment in course_enrollments]
)
def _assert_error_message(self, errors, error_key, logger, log_level, expected_error_msg):
logger.check_present((LOG_PATH, log_level, expected_error_msg))
self.assertDictEqual(
{
error_key: expected_error_msg
},
errors
)
class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
""" Tests for linking behavior """
@@ -149,7 +139,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
self._create_waiting_course_enrollment(another_program_enrollment, self.fruit_course)
self._create_waiting_course_enrollment(another_program_enrollment, self.animal_course)
link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
link_program_enrollments(self.program, {'0001': self.user_1.username})
self._assert_program_enrollment(self.user_1, self.program, '0001')
self._assert_user_enrolled_in_program_courses(
@@ -175,7 +165,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
status='inactive'
)
link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
link_program_enrollments(self.program, {'0001': self.user_1.username})
self._assert_program_enrollment(self.user_1, self.program, '0001')
@@ -209,7 +199,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
asserts that user_2 was not linked because the enrollment was not found
"""
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
@@ -222,7 +212,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
)
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
self.assertDictEqual(errors, {'0002': expected_error_msg})
self._assert_program_enrollment(self.user_1, self.program, '0001')
self._assert_no_program_enrollment(self.user_2, self.program)
@@ -231,7 +221,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
enrollment_2 = self._create_waiting_enrollment(self.program, '0002')
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
@@ -241,7 +231,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
expected_error_msg = NO_LMS_USER_TEMPLATE.format('nonexistant-user')
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg})
self.assertDictEqual(errors, {'0002': expected_error_msg})
self._assert_program_enrollment(self.user_1, self.program, '0001')
self._assert_no_user(enrollment_2)
@@ -256,17 +246,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
self._assert_program_enrollment(self.user_2, self.program, '0002', refresh=False)
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
'0002': self.user_2.username
}
)
expected_error_msg = user_already_linked_message(program_enrollment, self.user_2)
expected_error_msg = _user_already_linked_message(program_enrollment, self.user_2)
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
self.assertDictEqual(errors, {'0002': expected_error_msg})
self._assert_program_enrollment(self.user_1, self.program, '0001')
self._assert_program_enrollment(self.user_2, self.program, '0002')
@@ -283,17 +273,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
self._assert_program_enrollment(user_3, self.program, '0003', refresh=False)
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
'0003': self.user_2.username,
}
)
expected_error_msg = user_already_linked_message(enrollment, self.user_2)
expected_error_msg = _user_already_linked_message(enrollment, self.user_2)
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0003', self.user_2.username): expected_error_msg})
self.assertDictEqual(errors, {'0003': expected_error_msg})
self._assert_program_enrollment(self.user_1, self.program, '0001')
self._assert_no_program_enrollment(self.user_2, self.program)
self._assert_program_enrollment(user_3, self.program, '0003')
@@ -313,22 +303,14 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
self._create_waiting_course_enrollment(program_enrollment_2, self.fruit_course)
self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course)
msg = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
user=self.user_1.username, course=nonexistant_course
)
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
self.program,
{
'0001': self.user_1.username,
'0002': self.user_2.username
}
)
logger.check_present((LOG_PATH, 'ERROR', msg))
self.assertDictEqual(
errors, {('0001', self.user_1.username): 'NonExistentCourseError: ' + msg}
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
'0002': self.user_2.username
}
)
self.assertIn(errors['0001'], 'NonExistentCourseError: ')
self._assert_no_program_enrollment(self.user_1, self.program)
self._assert_no_user(program_enrollment_1)
course_enrollment_1.refresh_from_db()
@@ -348,46 +330,15 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001')
self._create_waiting_enrollment(self.program, '0002')
msg = 'Integrity error while linking program enrollments'
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
self.program,
{
'0001': self.user_1.username,
'0002': self.user_2.username,
}
)
logger.check_present((LOG_PATH, 'ERROR', msg))
errors = link_program_enrollments(
self.program,
{
'0001': self.user_1.username,
'0002': self.user_2.username,
}
)
self.assertEqual(len(errors), 1)
self.assertIn('UNIQUE constraint failed', errors[('0001', self.user_1.username)])
self.assertIn('UNIQUE constraint failed', errors['0001'])
self._assert_no_user(program_enrollment_1)
self._assert_program_enrollment(self.user_2, self.program, '0002')
def test_invalid_uuid(self):
self._create_waiting_enrollment(self.program, 'learner-0')
with self.assertRaisesMessage(ValueError, 'badly formed hexadecimal UUID string'):
link_program_enrollments_to_lms_users(
'notauuid::thisisntauuid',
{
'learner-0': self.user_1.username,
}
)
def test_None(self):
self._create_waiting_enrollment(self.program, 'learner-0')
msg = 'external_user_key or username cannot be None'
with self.assertRaisesMessage(ValueError, msg):
link_program_enrollments_to_lms_users(
self.program,
{
None: self.user_1.username,
}
)
with self.assertRaisesMessage(ValueError, msg):
link_program_enrollments_to_lms_users(
'notauuid::thisisntauuid',
{
'learner-0': None,
}
)

View File

@@ -1,5 +1,5 @@
"""
Tests for account linking Python API.
Tests for program enrollment reading Python API.
"""
from __future__ import absolute_import, unicode_literals
@@ -7,16 +7,30 @@ from uuid import UUID
import ddt
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from organizations.tests.factories import OrganizationFactory
from social_django.models import UserSocialAuth
from course_modes.models import CourseMode
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses as PCEStatuses
from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses as PEStatuses
from lms.djangoapps.program_enrollments.exceptions import (
OrganizationDoesNotExistException,
ProgramDoesNotExistException,
ProviderConfigurationException,
ProviderDoesNotExistException
)
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from third_party_auth.tests.factories import SAMLProviderConfigFactory
from ..reading import (
fetch_program_course_enrollments,
@@ -24,7 +38,8 @@ from ..reading import (
fetch_program_enrollments,
fetch_program_enrollments_by_student,
get_program_course_enrollment,
get_program_enrollment
get_program_enrollment,
get_users_by_external_keys
)
User = get_user_model()
@@ -425,3 +440,136 @@ class ProgramEnrollmentReadingTests(TestCase):
)
del result['usernames']
return result
class GetUsersByExternalKeysTests(CacheIsolationTestCase):
"""
Tests for the get_users_by_external_keys function
"""
ENABLED_CACHES = ['default']
@classmethod
def setUpTestData(cls):
super(GetUsersByExternalKeysTests, cls).setUpTestData()
cls.program_uuid = UUID('e7a82f8d-d485-486b-b733-a28222af92bf')
cls.organization_key = 'ufo'
cls.external_user_id = '1234'
cls.user_0 = UserFactory(username='user-0')
cls.user_1 = UserFactory(username='user-1')
cls.user_2 = UserFactory(username='user-2')
def setUp(self):
super(GetUsersByExternalKeysTests, self).setUp()
catalog_org = CatalogOrganizationFactory.create(key=self.organization_key)
program = ProgramFactory.create(
uuid=self.program_uuid,
authoring_organizations=[catalog_org]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
def create_social_auth_entry(self, user, provider, external_id):
"""
helper functio to create a user social auth entry
"""
UserSocialAuth.objects.create(
user=user,
uid='{0}:{1}'.format(provider.slug, external_id),
provider=provider.backend_name,
)
def test_happy_path(self):
"""
Test that get_users_by_external_keys returns the expected
mapping of external keys to users.
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
provider = SAMLProviderConfigFactory.create(organization=organization)
self.create_social_auth_entry(self.user_0, provider, 'ext-user-0')
self.create_social_auth_entry(self.user_1, provider, 'ext-user-1')
self.create_social_auth_entry(self.user_2, provider, 'ext-user-2')
requested_keys = {'ext-user-1', 'ext-user-2', 'ext-user-3'}
actual = get_users_by_external_keys(self.program_uuid, requested_keys)
# ext-user-0 not requested, ext-user-3 doesn't exist
expected = {
'ext-user-1': self.user_1,
'ext-user-2': self.user_2,
'ext-user-3': None,
}
assert actual == expected
def test_empty_request(self):
"""
Test that requesting no external keys does not cause an exception.
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
SAMLProviderConfigFactory.create(organization=organization)
actual = get_users_by_external_keys(self.program_uuid, set())
assert actual == {}
def test_catalog_program_does_not_exist(self):
"""
Test ProgramDoesNotExistException is thrown if the program cache does
not include the requested program uuid.
"""
fake_program_uuid = UUID('80cc59e5-003e-4664-a582-48da44bc7e12')
with self.assertRaises(ProgramDoesNotExistException):
get_users_by_external_keys(fake_program_uuid, [])
def test_catalog_program_missing_org(self):
"""
Test OrganizationDoesNotExistException is thrown if the cached program does not
have an authoring organization.
"""
program = ProgramFactory.create(
uuid=self.program_uuid,
authoring_organizations=[]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
with self.assertRaises(OrganizationDoesNotExistException):
get_users_by_external_keys(self.program_uuid, [])
def test_lms_organization_not_found(self):
"""
Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
matching the catalog program's authoring_organization
"""
organization = OrganizationFactory.create(short_name='some_other_org')
SAMLProviderConfigFactory.create(organization=organization)
with self.assertRaises(OrganizationDoesNotExistException):
get_users_by_external_keys(self.program_uuid, [])
def test_saml_provider_not_found(self):
"""
Test that Prov exception is thrown if no SAML provider exists for this
program's organization.
"""
OrganizationFactory.create(short_name=self.organization_key)
with self.assertRaises(ProviderDoesNotExistException):
get_users_by_external_keys(self.program_uuid, [])
def test_extra_saml_provider_disabled(self):
"""
If multiple samlprovider records exist with the same organization,
but the extra record is disabled, no exception is raised.
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
SAMLProviderConfigFactory.create(organization=organization)
# create a second active config for the same organization, NOT enabled
SAMLProviderConfigFactory.create(
organization=organization, slug='foox', enabled=False
)
get_users_by_external_keys(self.program_uuid, [])
def test_extra_saml_provider_enabled(self):
"""
If multiple enabled samlprovider records exist with the same organization
an exception is raised.
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
SAMLProviderConfigFactory.create(organization=organization)
# create a second active config for the same organizationm, IS enabled
SAMLProviderConfigFactory.create(
organization=organization, slug='foox', enabled=True
)
with self.assertRaises(ProviderConfigurationException):
get_users_by_external_keys(self.program_uuid, [])

View File

@@ -0,0 +1,12 @@
"""
(Future home of) Tests for program enrollment writing Python API.
Currently, we do not directly unit test the functions in api/writing.py.
This is okay for now because they are all used in
`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
Eventually it would be good to directly test the Python API function and just use
mocks in the view tests.
This file serves as a placeholder and reminder to do that the next time there
is development on the program_enrollments writing API.
"""
from __future__ import absolute_import, unicode_literals

View File

@@ -0,0 +1,426 @@
"""
Python API functions related to writing program enrollments.
Outside of this subpackage, import these functions
from `lms.djangoapps.program_enrollments.api`.
"""
from __future__ import absolute_import, unicode_literals
import logging
from course_modes.models import CourseMode
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseEnrollment, NonExistentCourseError
from ..constants import ProgramCourseEnrollmentStatuses
from ..constants import ProgramCourseOperationStatuses as ProgramCourseOpStatuses
from ..constants import ProgramEnrollmentStatuses
from ..constants import ProgramOperationStatuses as ProgramOpStatuses
from ..exceptions import ProviderDoesNotExistException
from ..models import ProgramCourseEnrollment, ProgramEnrollment
from .reading import fetch_program_course_enrollments, fetch_program_enrollments, get_users_by_external_keys
logger = logging.getLogger(__name__)
def write_program_enrollments(program_uuid, enrollment_requests, create, update):
"""
Bulk create/update a set of program enrollments.
Arguments:
program_uuid (UUID|str)
enrollment_requests (list[dict]): dicts in the form:
* 'external_user_key': str
* 'status': str from ProgramEnrollmentStatuses
* 'curriculum_uuid': str, omittable if `create==False`.
create (bool): non-existent enrollments will be created iff `create`,
otherwise they will be skipped as 'duplicate'.
update (bool): existing enrollments will be updated iff `update`,
otherwise they will be skipped as 'not-in-program'
At least one of `create` or `update` must be True.
Returns: dict[str: str]
Mapping of external user keys to strings from ProgramOperationStatuses.
"""
if not (create or update):
raise ValueError("At least one of (create, update) must be True")
requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
external_keys = set(requests_by_key)
try:
users_by_key = get_users_by_external_keys(program_uuid, external_keys)
except ProviderDoesNotExistException:
# Organization has not yet set up their identity provider.
# Just act as if none of the external users have been registered.
users_by_key = {key: None for key in external_keys}
# Fetch existing program enrollments.
existing_enrollments = fetch_program_enrollments(
program_uuid=program_uuid, external_user_keys=external_keys
)
existing_enrollments_by_key = {key: None for key in external_keys}
existing_enrollments_by_key.update({
enrollment.external_user_key: enrollment
for enrollment in existing_enrollments
})
# For each enrollment request, try to create/update:
# * For creates, build up list `to_save`, which we will bulk-create afterwards.
# * For updates, do them in place.
# (TODO: Django 2.2 will add bulk-update support, which we could use here)
# Update `results` with the new status or an error status for each operation.
results = {}
to_save = []
for external_key, request in requests_by_key.items():
status = request['status']
if status not in ProgramEnrollmentStatuses.__ALL__:
results[external_key] = ProgramOpStatuses.INVALID_STATUS
continue
user = users_by_key[external_key]
existing_enrollment = existing_enrollments_by_key.get(external_key)
if existing_enrollment:
if not update:
results[external_key] = ProgramOpStatuses.CONFLICT
continue
results[external_key] = change_program_enrollment_status(
existing_enrollment, status
)
else:
if not create:
results[external_key] = ProgramOpStatuses.NOT_IN_PROGRAM
continue
new_enrollment = create_program_enrollment(
program_uuid=program_uuid,
curriculum_uuid=request['curriculum_uuid'],
user=user,
external_user_key=external_key,
status=status,
save=False,
)
to_save.append(new_enrollment)
results[external_key] = new_enrollment.status
# Bulk-create all new program enrollments.
# Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
# See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
if to_save:
ProgramEnrollment.objects.bulk_create(to_save)
results.update({key: ProgramOpStatuses.DUPLICATED for key in duplicated_keys})
return results
def create_program_enrollment(
program_uuid,
curriculum_uuid,
user,
external_user_key,
status,
save=True,
):
"""
Create a program enrollment.
Arguments:
program_uuid (UUID|str)
curriculum_uuid (str)
user (User)
external_user_key (str)
status (str): from ProgramEnrollmentStatuses
save (bool): Whether to save the created ProgamEnrollment.
Defaults to True. One may set this to False in order to
bulk-create the enrollments.
Returns: ProgramEnrollment
"""
if not (user or external_user_key):
raise ValueError("At least one of (user, external_user_key) must be ")
program_enrollment = ProgramEnrollment(
program_uuid=program_uuid,
curriculum_uuid=curriculum_uuid,
user=user,
external_user_key=external_user_key,
status=status,
)
if save:
program_enrollment.save()
return program_enrollment
def change_program_enrollment_status(program_enrollment, new_status):
"""
Update a program enrollment with a new status.
Arguments:
program_enrollment (ProgramEnrollment)
status (str): from ProgramCourseEnrollmentStatuses
Returns: str
String from ProgramOperationStatuses.
"""
if new_status not in ProgramEnrollmentStatuses.__ALL__:
return ProgramOpStatuses.INVALID_STATUS
program_enrollment.status = new_status
program_enrollment.save()
return program_enrollment.status
def write_program_course_enrollments(
program_uuid,
course_key,
enrollment_requests,
create,
update,
):
"""
Bulk create/update a set of program-course enrollments.
Arguments:
program_uuid (UUID|str)
enrollment_requests (list[dict]): dicts in the form:
* 'external_user_key': str
* 'status': str from ProgramCourseEnrollmentStatuses
create (bool): non-existent enrollments will be created iff `create`,
otherwise they will be skipped as 'duplicate'.
update (bool): existing enrollments will be updated iff `update`,
otherwise they will be skipped as 'not-in-program'
At least one of `create` or `update` must be True.
Returns: dict[str: str]
Mapping of external user keys to strings from ProgramCourseOperationStatuses.
"""
if not (create or update):
raise ValueError("At least one of (create, update) must be True")
requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
external_keys = set(requests_by_key)
program_enrollments = fetch_program_enrollments(
program_uuid=program_uuid,
external_user_keys=external_keys,
).prefetch_related('program_course_enrollments')
program_enrollments_by_key = {
enrollment.external_user_key: enrollment for enrollment in program_enrollments
}
# Fetch existing program-course enrollments.
existing_course_enrollments = fetch_program_course_enrollments(
program_uuid, course_key, program_enrollments=program_enrollments,
)
existing_course_enrollments_by_key = {key: None for key in external_keys}
existing_course_enrollments_by_key.update({
enrollment.program_enrollment.external_user_key: enrollment
for enrollment in existing_course_enrollments
})
# For each enrollment request, try to create/update.
# For creates, build up list `to_save`, which we will bulk-create afterwards.
# For updates, do them in place (Django 2.2 will add bulk-update support).
# For each operation, update `results` with the new status or an error status.
results = {}
to_save = []
for external_key, request in requests_by_key.items():
status = request['status']
program_enrollment = program_enrollments_by_key.get(external_key)
if not program_enrollment:
results[external_key] = ProgramCourseOpStatuses.NOT_IN_PROGRAM
continue
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
results[external_key] = ProgramCourseOpStatuses.INVALID_STATUS
continue
existing_course_enrollment = existing_course_enrollments_by_key[external_key]
if existing_course_enrollment:
if not update:
results[external_key] = ProgramCourseOpStatuses.CONFLICT
continue
results[external_key] = change_program_course_enrollment_status(
existing_course_enrollment, status
)
else:
if not create:
results[external_key] = ProgramCourseOpStatuses.NOT_FOUND
continue
new_course_enrollment = create_program_course_enrollment(
program_enrollment, course_key, status, save=False
)
to_save.append(new_course_enrollment)
results[external_key] = new_course_enrollment.status
# Bulk-create all new program-course enrollments.
# Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
# See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
if to_save:
ProgramCourseEnrollment.objects.bulk_create(to_save)
results.update({
key: ProgramCourseOpStatuses.DUPLICATED for key in duplicated_keys
})
return results
def create_program_course_enrollment(program_enrollment, course_key, status, save=True):
"""
Create a program course enrollment.
If `program_enrollment` is realized (i.e., has a non-null User),
then also create a course enrollment.
Arguments:
program_enrollment (ProgramEnrollment)
course_key (CourseKey|str)
status (str): from ProgramCourseEnrollmentStatuses
save (bool): Whether to save the created ProgamCourseEnrollment.
Defaults to True. One may set this to False in order to
bulk-create the enrollments.
Note that if a CourseEnrollment is created, it will be saved
regardless of this value.
Returns: ProgramCourseEnrollment
Raises: NonExistentCourseError
"""
_ensure_course_exists(course_key, program_enrollment.external_user_key)
course_enrollment = (
enroll_in_masters_track(program_enrollment.user, course_key, status)
if program_enrollment.user
else None
)
program_course_enrollment = ProgramCourseEnrollment(
program_enrollment=program_enrollment,
course_key=course_key,
course_enrollment=course_enrollment,
status=status,
)
if save:
program_course_enrollment.save()
return program_course_enrollment
def change_program_course_enrollment_status(program_course_enrollment, new_status):
"""
Update a program course enrollment with a new status.
If `program_course_enrollment` is realized with a CourseEnrollment,
then also update that.
Arguments:
program_course_enrollment (ProgramCourseEnrollment)
status (str): from ProgramCourseEnrollmentStatuses
Returns: str
String from ProgramOperationCourseStatuses.
"""
if new_status == program_course_enrollment.status:
return new_status
if new_status == ProgramCourseEnrollmentStatuses.ACTIVE:
active = True
elif new_status == ProgramCourseEnrollmentStatuses.INACTIVE:
active = False
else:
return ProgramCourseOpStatuses.INVALID_STATUS
if program_course_enrollment.course_enrollment:
if active:
program_course_enrollment.course_enrollment.activate()
else:
program_course_enrollment.course_enrollment.deactivate()
program_course_enrollment.status = new_status
program_course_enrollment.save()
return program_course_enrollment.status
def enroll_in_masters_track(user, course_key, status):
"""
Ensure that the user is enrolled in the Master's track of course.
Either creates or updates a course enrollment.
Arguments:
user (User)
course_key (CourseKey|str)
status (str): from ProgramCourseEnrollmenStatuses
Returns: CourseEnrollment
Raises: NonExistentCourseError
"""
_ensure_course_exists(course_key, user.id)
if status not in ProgramCourseEnrollmentStatuses.__ALL__:
raise ValueError("invalid ProgramCourseEnrollmenStatus: {}".format(status))
if CourseEnrollment.is_enrolled(user, course_key):
course_enrollment = CourseEnrollment.objects.get(
user=user,
course_id=course_key,
)
if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
course_enrollment.mode = CourseMode.MASTERS
course_enrollment.save()
message_template = (
"Converted course enrollment for user id={} "
"and course key={} from mode {} to Master's."
)
logger.info(
message_template.format(user.id, course_key, course_enrollment.mode)
)
elif course_enrollment.mode != CourseMode.MASTERS:
error_message = (
"Cannot convert CourseEnrollment to Master's from mode {}. "
"user id={}, course_key={}."
).format(
course_enrollment.mode, user.id, course_key
)
logger.error(error_message)
else:
course_enrollment = CourseEnrollment.enroll(
user,
course_key,
mode=CourseMode.MASTERS,
check_access=False,
)
if course_enrollment.mode == CourseMode.MASTERS:
if status == ProgramCourseEnrollmentStatuses.INACTIVE:
course_enrollment.deactivate()
return course_enrollment
def _ensure_course_exists(course_key, user_key_or_id):
"""
Log and raise an error if `course_key` does not refer to a real course run.
`user_key_or_id` should be a non-PII value identifying the user that
can be used in the log message.
"""
if CourseOverview.course_exists(course_key):
return
logger.error(
"Cannot enroll user={} in non-existent course={}".format(
user_key_or_id,
course_key,
)
)
raise NonExistentCourseError
def _organize_requests_by_external_key(enrollment_requests):
"""
Get dict of enrollment requests by external key.
External keys associated with more than one request are split out into a set,
and their enrollment requests thrown away.
Arguments:
enrollment_requests (list[dict])
Returns:
(requests_by_key, duplicated_keys)
where requests_by_key is dict[str: dict]
and duplicated_keys is set[str].
"""
requests_by_key = {}
duplicated_keys = set()
for request in enrollment_requests:
key = request['external_user_key']
if key in duplicated_keys:
continue
if key in requests_by_key:
duplicated_keys.add(key)
del requests_by_key[key]
continue
requests_by_key[key] = request
return requests_by_key, duplicated_keys

View File

@@ -40,3 +40,76 @@ class ProgramCourseEnrollmentStatuses(object):
__MODEL_CHOICES__ = (
(status, status) for status in __ALL__
)
class _EnrollmentErrorStatuses(object):
"""
Error statuses common to program and program-course enrollments responses.
"""
# Same student key supplied more than once.
DUPLICATED = 'duplicated'
# Requested target status is invalid
INVALID_STATUS = "invalid-status"
# In the case of a POST request, the enrollment already exists.
CONFLICT = "conflict"
# Although the request is syntactically valid,
# the change being made is not supported.
# For example, it may be illegal to change a user's status back to A
# after changing it to B, where A and B are two hypothetical enrollment
# statuses.
ILLEGAL_OPERATION = "illegal-operation"
# Could not modify program enrollment or create program-course
# enrollment because the student is not enrolled in the program in the
# first place.
NOT_IN_PROGRAM = "not-in-program"
# Something unexpected went wrong.
# If API users are seeing this, we need to investigate.
INTERNAL_ERROR = "internal-error"
__ALL__ = (
DUPLICATED,
INVALID_STATUS,
CONFLICT,
ILLEGAL_OPERATION,
NOT_IN_PROGRAM,
INTERNAL_ERROR,
)
class ProgramOperationStatuses(
ProgramEnrollmentStatuses,
_EnrollmentErrorStatuses,
):
"""
Valid program enrollment operation statuses.
Combines error statuses and OK statuses.
"""
__OK__ = ProgramEnrollmentStatuses.__ALL__
__ERRORS__ = _EnrollmentErrorStatuses.__ALL__
__ALL__ = __OK__ + __ERRORS__
class ProgramCourseOperationStatuses(
ProgramCourseEnrollmentStatuses,
_EnrollmentErrorStatuses,
):
"""
Valid program-course enrollment operation statuses.
Combines error statuses and OK statuses.
"""
# Could not modify program-course enrollment because the user
# is not enrolled in the course in the first place.
NOT_FOUND = "not-found"
__OK__ = ProgramCourseEnrollmentStatuses.__ALL__
__ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
__ALL__ = __OK__ + __ERRORS__

View File

@@ -0,0 +1,65 @@
"""
Exceptions raised by functions exposed by program_enrollments Django app.
"""
from __future__ import absolute_import, unicode_literals
# Every `__init__` here calls empty Exception() constructor.
# pylint: disable=super-init-not-called
class ProgramDoesNotExistException(Exception):
def __init__(self, program_uuid):
self.program_uuid = program_uuid
def __str__(self):
return 'Unable to find catalog program matching uuid {}'.format(self.program_uuid)
class OrganizationDoesNotExistException(Exception):
pass
class ProgramHasNoAuthoringOrganizationException(OrganizationDoesNotExistException):
def __init__(self, program_uuid):
self.program_uuid = program_uuid
def __str__(self):
return (
'Cannot determine authoring organization key for catalog program {}'
).format(self.program_uuid)
class BadOrganizationShortNameException(OrganizationDoesNotExistException):
def __init__(self, organization_short_name):
self.organization_short_name = organization_short_name
def __str__(self):
return 'Unable to find organization for short_name {}'.format(
self.organization_short_name
)
class ProviderDoesNotExistException(Exception):
def __init__(self, organization):
self.organization = organization
def __str__(self):
return 'Unable to find organization for short_name {}'.format(
self.organization.id
)
class ProviderConfigurationException(Exception):
def __init__(self, organization):
self.organization = organization
def __str__(self):
return (
'Multiple active SAML configurations found for organization={}. '
'Expected one.'
).format(self.organization.short_name)

View File

@@ -1,18 +1,18 @@
""" Management command to link program enrollments and external student_keys to an LMS user """
from __future__ import absolute_import, unicode_literals
import logging
from uuid import UUID
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
from lms.djangoapps.program_enrollments.api import link_program_enrollments
logger = logging.getLogger(__name__)
User = get_user_model()
INCORRECT_PARAMETER_TEMPLATE = (
'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
"incorrectly formatted argument '{}', "
"must be in form <external user key>:<lms username>"
)
DUPLICATE_KEY_TEMPLATE = 'external user key {} provided multiple times'
@@ -70,11 +70,17 @@ class Command(BaseCommand):
# pylint: disable=arguments-differ
def handle(self, program_uuid, user_items, *args, **options):
try:
parsed_program_uuid = UUID(program_uuid)
except ValueError:
raise CommandError("supplied program_uuid '{}' is not a valid UUID")
ext_keys_to_usernames = self.parse_user_items(user_items)
try:
link_program_enrollments_to_lms_users(program_uuid, ext_keys_to_usernames)
link_program_enrollments(
parsed_program_uuid, ext_keys_to_usernames
)
except Exception as e:
raise CommandError(e)
raise CommandError(str(e))
def parse_user_items(self, user_items):
"""
@@ -82,18 +88,21 @@ class Command(BaseCommand):
list of strings in the format 'external_user_key:lms_username'
Returns:
dict mapping external user keys to lms usernames
Raises:
CommandError
"""
result = {}
for user_item in user_items:
split_args = user_item.split(':')
if len(split_args) != 2:
message = (INCORRECT_PARAMETER_TEMPLATE).format(user_item)
message = INCORRECT_PARAMETER_TEMPLATE.format(user_item)
raise CommandError(message)
external_user_key = split_args[0].strip()
lms_username = split_args[1].strip()
if not (external_user_key and lms_username):
message = INCORRECT_PARAMETER_TEMPLATE.format(user_item)
raise CommandError(message)
external_user_key = split_args[0]
lms_username = split_args[1]
if external_user_key in result:
raise CommandError(DUPLICATE_KEY_TEMPLATE.format(external_user_key))
result[external_user_key] = lms_username
return result

View File

@@ -3,6 +3,8 @@ Tests for the link_program_enrollments management command.
"""
from __future__ import absolute_import
from uuid import UUID
import mock
from django.core.management import call_command
from django.core.management.base import CommandError
@@ -15,13 +17,13 @@ _COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_pro
class TestLinkProgramEnrollmentManagementCommand(TestCase):
"""
Test that the command calls link_program_enrollments_to_lms_users
Test that the command calls link_program_enrollments
correctly and handles exceptional input correctly.
"""
program_uuid = 'a32c5da8-fb89-4f1e-97a7-b13de9e6dfa2'
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments_to_lms_users"
_LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments"
@mock.patch(_LINKING_FUNCTION_MOCK_PATH, autospec=True)
def test_good_input_calls_linking(self, mock_link):
@@ -29,7 +31,7 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
Command(), self.program_uuid, 'learner-01:user-01', 'learner-02:user-02'
)
mock_link.assert_called_once_with(
self.program_uuid,
UUID(self.program_uuid),
{
'learner-01': 'user-01',
'learner-02': 'user-02',
@@ -45,6 +47,24 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
Command(), self.program_uuid, 'learner-01:user-01', 'whoops', 'learner-03:user-03'
)
def test_missing_external_user_key(self):
with self.assertRaisesRegex(
CommandError,
INCORRECT_PARAMETER_TEMPLATE.format('whoops: ')
):
call_command(
Command(), self.program_uuid, 'learner-01:user-01', 'whoops: ', 'learner-03:user-03'
)
def test_missing_username(self):
with self.assertRaisesRegex(
CommandError,
INCORRECT_PARAMETER_TEMPLATE.format(' :whoops')
):
call_command(
Command(), self.program_uuid, 'learner-01:user-01', ' :whoops', 'learner-03:user-03'
)
def test_repeated_user_key_exception(self):
with self.assertRaisesRegex(
CommandError,
@@ -53,3 +73,10 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
call_command(
Command(), self.program_uuid, 'learner-01:user-01', 'learner-01:user-02'
)
def test_invalid_uuid(self):
error_regex = r"supplied program_uuid '.*' is not a valid UUID"
with self.assertRaisesRegex(CommandError, error_regex):
call_command(
Command(), 'notauuid::thisisntauuid', 'learner-0:user-01'
)

View File

@@ -4,8 +4,6 @@ Django model specifications for the Program Enrollments API
"""
from __future__ import absolute_import, unicode_literals
import logging
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
@@ -13,16 +11,11 @@ from django.utils.translation import ugettext_lazy as _
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
from six import text_type
from course_modes.models import CourseMode
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseEnrollment, NonExistentCourseError
from student.models import CourseEnrollment
from .constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unicode
"""
@@ -82,17 +75,6 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
enrollments.update(external_user_key=None)
return True
def get_program_course_enrollment(self, course_key):
"""
Returns the ProgramCourseEnrollment associated with this ProgramEnrollment and given course,
None if it does not exist
"""
try:
program_course_enrollment = self.program_course_enrollments.get(course_key=course_key)
except ProgramCourseEnrollment.DoesNotExist:
return None
return program_course_enrollment
def __str__(self):
return '[ProgramEnrollment id={}]'.format(self.id)
@@ -136,89 +118,3 @@ class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missin
def __str__(self):
return '[ProgramCourseEnrollment id={}]'.format(self.id)
@classmethod
def create_program_course_enrollment(cls, program_enrollment, course_key, status):
"""
Create ProgramCourseEnrollment for the given course and program enrollment
"""
program_course_enrollment = ProgramCourseEnrollment.objects.create(
program_enrollment=program_enrollment,
course_key=course_key,
status=status,
)
if program_enrollment.user:
program_course_enrollment.enroll(program_enrollment.user)
return program_course_enrollment.status
def change_status(self, status):
"""
Modify ProgramCourseEnrollment status and course_enrollment status if it exists
"""
if status == self.status:
return status
self.status = status
if self.course_enrollment:
if status == ProgramCourseEnrollmentStatuses.ACTIVE:
self.course_enrollment.activate()
elif status == ProgramCourseEnrollmentStatuses.INACTIVE:
self.course_enrollment.deactivate()
else:
message = ("Changed {enrollment} status to {status}, not changing course_enrollment"
" status because status is not '{active}' or '{inactive}'")
logger.warn(message.format(
enrollment=self,
status=status,
active=ProgramCourseEnrollmentStatuses.ACTIVE,
inactive=ProgramCourseEnrollmentStatuses.INACTIVE
))
elif self.program_enrollment.user:
logger.warn("User {user} {program_enrollment} {course_key} has no course_enrollment".format(
user=self.program_enrollment.user,
program_enrollment=self.program_enrollment,
course_key=self.course_key,
))
self.save()
return self.status
def enroll(self, user):
"""
Create a CourseEnrollment to enroll user in course
"""
try:
CourseOverview.get_from_id(self.course_key)
except CourseOverview.DoesNotExist:
logger.warning(
"User %s failed to enroll in non-existent course %s", user.id,
text_type(self.course_key),
)
raise NonExistentCourseError
if CourseEnrollment.is_enrolled(user, self.course_key):
course_enrollment = CourseEnrollment.objects.get(
user=user,
course_id=self.course_key,
)
if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
course_enrollment.mode = CourseMode.MASTERS
course_enrollment.save()
self.course_enrollment = course_enrollment
message_template = (
"Attempted to create course enrollment for user={user} "
"and course={course} but an enrollment already exists. "
"Existing enrollment will be used instead."
)
logger.info(message_template.format(user=user.id, course=self.course_key))
else:
self.course_enrollment = CourseEnrollment.enroll(
user,
self.course_key,
mode=CourseMode.MASTERS,
check_access=False,
)
if self.status == ProgramCourseEnrollmentStatuses.INACTIVE:
self.course_enrollment.deactivate()
self.save()

View File

@@ -3,8 +3,6 @@ Constants used throughout the program_enrollments V1 API.
"""
from __future__ import absolute_import, unicode_literals
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
# Captures strings composed of alphanumeric characters a-f and dashes.
PROGRAM_UUID_PATTERN = r'(?P<program_uuid>[A-Fa-f0-9-]+)'
@@ -19,79 +17,6 @@ REQUEST_STUDENT_KEY = 'student_key'
ENABLE_ENROLLMENT_RESET_FLAG = 'ENABLE_ENROLLMENT_RESET'
class _EnrollmentErrorStatuses(object):
"""
Error statuses common to program and program-course enrollments responses.
"""
# Same student key supplied more than once.
DUPLICATED = 'duplicated'
# Requested target status is invalid
INVALID_STATUS = "invalid-status"
# In the case of a POST request, the enrollment already exists.
CONFLICT = "conflict"
# Although the request is syntactically valid,
# the change being made is not supported.
# For example, it may be illegal to change a user's status back to A
# after changing it to B, where A and B are two hypothetical enrollment
# statuses.
ILLEGAL_OPERATION = "illegal-operation"
# Could not modify program enrollment or create program-course
# enrollment because the student is not enrolled in the program in the
# first place.
NOT_IN_PROGRAM = "not-in-program"
# Something unexpected went wrong.
# If API users are seeing this, we need to investigate.
INTERNAL_ERROR = "internal-error"
__ALL__ = (
DUPLICATED,
INVALID_STATUS,
CONFLICT,
ILLEGAL_OPERATION,
NOT_IN_PROGRAM,
INTERNAL_ERROR,
)
class ProgramResponseStatuses(
ProgramEnrollmentStatuses,
_EnrollmentErrorStatuses,
):
"""
Valid program enrollment response statuses.
Combines error statuses and OK statuses.
"""
__OK__ = ProgramEnrollmentStatuses.__ALL__
__ERRORS__ = _EnrollmentErrorStatuses.__ALL__
__ALL__ = __OK__ + __ERRORS__
class ProgramCourseResponseStatuses(
ProgramCourseEnrollmentStatuses,
_EnrollmentErrorStatuses,
):
"""
Valid program-course enrollment response statuses.
Combines error statuses and OK statuses.
"""
# Could not modify program-course enrollment because the user
# is not enrolled in the course in the first place.
NOT_FOUND = "not-found"
__OK__ = ProgramCourseEnrollmentStatuses.__ALL__
__ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
__ALL__ = __OK__ + __ERRORS__
class CourseRunProgressStatuses(object):
"""
Statuses that a course run can be in with respect to user progress.

View File

@@ -6,7 +6,6 @@ from __future__ import absolute_import, unicode_literals
from rest_framework import serializers
from six import text_type
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from .constants import CourseRunProgressStatuses
@@ -45,6 +44,32 @@ class ProgramEnrollmentSerializer(serializers.Serializer):
return bool(obj.user)
class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
"""
Base fields for all program enrollment related serializers.
"""
student_key = serializers.CharField(allow_blank=False, source='external_user_key')
# We could have made this a ChoiceField on ProgramEnrollmentStatuses.__ALL__;
# however, we instead check statuses in api/writing.py,
# returning INVALID_STATUS for individual bad statuses instead of raising
# a ValidationError for the entire request.
status = serializers.CharField(allow_blank=False)
class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment creation requests.
"""
curriculum_uuid = serializers.UUIDField()
class ProgramEnrollmentUpdateRequestSerializer(ProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment update requests.
"""
pass
class ProgramCourseEnrollmentSerializer(serializers.Serializer):
"""
Serializer for displaying program-course enrollments.
@@ -67,40 +92,16 @@ class ProgramCourseEnrollmentSerializer(serializers.Serializer):
return text_type(obj.program_enrollment.curriculum_uuid)
class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
"""
Base fields for all program enrollment related serializers.
"""
student_key = serializers.CharField()
status = serializers.ChoiceField(
allow_blank=False,
choices=ProgramEnrollmentStatuses.__ALL__,
)
class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment creation requests.
"""
curriculum_uuid = serializers.UUIDField()
class ProgramEnrollmentModifyRequestSerializer(ProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment modification requests
"""
pass
class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidStatusMixin):
"""
Serializer for request to create a ProgramCourseEnrollment
"""
student_key = serializers.CharField(allow_blank=False)
status = serializers.ChoiceField(
allow_blank=False,
choices=ProgramCourseEnrollmentStatuses.__ALL__,
)
student_key = serializers.CharField(allow_blank=False, source='external_user_key')
# We could have made this a ChoiceField on ProgramCourseEnrollmentStatuses.__ALL__;
# however, we instead check statuses in api/writing.py,
# returning INVALID_STATUS for individual bad statuses instead of raising
# a ValidationError for the entire request.
status = serializers.CharField(allow_blank=False)
class ProgramCourseGradeSerializer(serializers.Serializer):
@@ -110,7 +111,7 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
Meant to be used with BaseProgramCourseGrade.
"""
# Required
student_key = serializers.CharField()
student_key = serializers.SerializerMethodField()
# From ProgramCourseGradeOk only
passed = serializers.BooleanField(required=False)
@@ -120,6 +121,9 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
# From ProgramCourseGradeError only
error = serializers.CharField(required=False)
def get_student_key(self, obj):
return obj.program_course_enrollment.program_enrollment.external_user_key
class DueDateSerializer(serializers.Serializer):
"""
@@ -158,62 +162,3 @@ class CourseRunOverviewListSerializer(serializers.Serializer):
Serializer for a list of course run overviews.
"""
course_runs = serializers.ListField(child=CourseRunOverviewSerializer())
# TODO: The following classes are not serializers, and should probably
# be moved to api.py as part of EDUCATOR-4321.
class BaseProgramCourseGrade(object):
"""
Base for either a courserun grade or grade-loading failure.
Can be passed to ProgramCourseGradeResultSerializer.
"""
is_error = None # Override in subclass
def __init__(self, program_course_enrollment):
"""
Given a ProgramCourseEnrollment,
create a BaseProgramCourseGradeResult instance.
"""
self.student_key = (
program_course_enrollment.program_enrollment.external_user_key
)
class ProgramCourseGradeOk(BaseProgramCourseGrade):
"""
Represents a courserun grade for a user enrolled through a program.
"""
is_error = False
def __init__(self, program_course_enrollment, course_grade):
"""
Given a ProgramCourseEnrollment and course grade object,
create a ProgramCourseGradeOk.
"""
super(ProgramCourseGradeOk, self).__init__(
program_course_enrollment
)
self.passed = course_grade.passed
self.percent = course_grade.percent
self.letter_grade = course_grade.letter_grade
class ProgramCourseGradeError(BaseProgramCourseGrade):
"""
Represents a failure to load a courserun grade for a user enrolled through
a program.
"""
is_error = True
def __init__(self, program_course_enrollment, exception=None):
"""
Given a ProgramCourseEnrollment and an Exception,
create a ProgramCourseGradeError.
"""
super(ProgramCourseGradeError, self).__init__(
program_course_enrollment
)
self.error = text_type(exception) if exception else "Unknown error"

View File

@@ -4,6 +4,7 @@ Unit tests for ProgramEnrollment views.
from __future__ import absolute_import, unicode_literals
import json
from collections import defaultdict
from datetime import datetime, timedelta
from uuid import UUID, uuid4
@@ -28,9 +29,12 @@ from course_modes.models import CourseMode
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory
from lms.djangoapps.grades.api import CourseGradeFactory
from lms.djangoapps.program_enrollments.constants import ProgramCourseOperationStatuses as CourseStatuses
from lms.djangoapps.program_enrollments.constants import ProgramOperationStatuses as ProgramStatuses
from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from lms.djangoapps.program_enrollments.utils import ProviderDoesNotExistException
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory,
@@ -55,11 +59,18 @@ from ..constants import (
REQUEST_STUDENT_KEY,
CourseRunProgressStatuses
)
from ..constants import ProgramCourseResponseStatuses as CourseStatuses
from ..constants import ProgramResponseStatuses as ProgramStatuses
_REST_API_MOCK_FMT = 'lms.djangoapps.program_enrollments.rest_api.{}'
_VIEW_MOCK_FMT = _REST_API_MOCK_FMT.format('v1.views.{}')
_DJANGOAPP_PATCH_FORMAT = 'lms.djangoapps.program_enrollments.{}'
_REST_API_PATCH_FORMAT = _DJANGOAPP_PATCH_FORMAT.format('rest_api.v1.{}')
_VIEW_PATCH_FORMAT = _REST_API_PATCH_FORMAT.format('views.{}')
_get_users_patch_path = _DJANGOAPP_PATCH_FORMAT.format('api.writing.get_users_by_external_keys')
_patch_get_users = mock.patch(
_get_users_patch_path,
autospec=True,
return_value=defaultdict(lambda: None),
)
class ProgramCacheMixin(CacheIsolationMixin):
@@ -86,8 +97,9 @@ class EnrollmentsDataMixin(ProgramCacheMixin):
def setUpClass(cls):
super(EnrollmentsDataMixin, cls).setUpClass()
cls.start_cache_isolation()
cls.organization_key = "orgkey"
cls.organization_key = "testorg"
catalog_org = OrganizationFactory(key=cls.organization_key)
LMSOrganizationFactory(short_name=cls.organization_key)
cls.program_uuid = UUID('00000000-1111-2222-3333-444444444444')
cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}'
cls.curriculum_uuid = UUID('aaaaaaaa-1111-2222-3333-444444444444')
@@ -334,7 +346,6 @@ class ProgramEnrollmentsGetTests(EnrollmentsDataMixin, APITestCase):
class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
""" Mixin class that defines common tests for program enrollment write endpoints """
add_uuid = False
success_status = 200
view_name = 'programs_api:v1:program_enrollments'
@@ -381,8 +392,7 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
json.dumps([{'status': 'enrolled'}]),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
self.assertEqual(response.data, 'invalid enrollment record')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_program_unauthorized(self):
student = UserFactory.create(password='password')
@@ -414,22 +424,17 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
response = self.request(url, json.dumps(enrollments), content_type='application/json')
self.assertEqual(422, response.status_code)
self.assertEqual('invalid enrollment record', response.data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_extra_field(self):
self.student_enrollment('pending', 'learner-01', prepare_student=True)
enrollment = self.student_enrollment('enrolled', 'learner-01')
enrollment['favorite_pokemon'] = 'bulbasaur'
enrollments = [enrollment]
with mock.patch(
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
autospec=True,
return_value=None
):
with _patch_get_users:
url = self.get_url()
response = self.request(url, json.dumps(enrollments), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
self.assertEqual(200, response.status_code)
self.assertDictEqual(
response.data,
{'learner-01': 'enrolled'}
@@ -442,8 +447,6 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
Tests for the ProgramEnrollment view POST method.
"""
add_uuid = True
success_status = status.HTTP_201_CREATED
success_status = 201
view_name = 'programs_api:v1:program_enrollments'
@@ -470,14 +473,10 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
]
url = self.get_url(program_uuid=0)
with mock.patch(
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
autospec=True,
return_value=None
):
with _patch_get_users:
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.status_code, 200)
for i in range(3):
enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i])
@@ -499,12 +498,14 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
user = User.objects.create_user('test_user', 'test@example.com', 'password')
url = self.get_url()
with mock.patch(
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
autospec=True,
return_value=user
_get_users_patch_path,
autospec=True,
return_value={'abc1': user},
):
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self.client.post(
url, json.dumps(post_data), content_type='application/json'
)
self.assertEqual(response.status_code, 200)
enrollment = ProgramEnrollment.objects.get(external_user_key='abc1')
self.assertEqual(enrollment.external_user_key, 'abc1')
self.assertEqual(enrollment.program_uuid, self.program_uuid)
@@ -523,13 +524,13 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
url = self.get_url()
with mock.patch(
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
autospec=True,
side_effect=ProviderDoesNotExistException()
_get_users_patch_path,
autospec=True,
side_effect=ProviderDoesNotExistException(None),
):
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.status_code, 200)
for i in range(3):
enrollment = ProgramEnrollment.objects.get(external_user_key='abc{}'.format(i))
@@ -546,7 +547,6 @@ class ProgramEnrollmentsPatchTests(ProgramEnrollmentsWriteMixin, APITestCase):
Tests for the ProgramEnrollment view PATCH method.
"""
add_uuid = False
success_status = status.HTTP_200_OK
def setUp(self):
super(ProgramEnrollmentsPatchTests, self).setUp()
@@ -692,19 +692,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
Tests for the ProgramEnrollment view PATCH method.
"""
add_uuid = True
success_status = status.HTTP_200_OK
def setUp(self):
super(ProgramEnrollmentsPutTests, self).setUp()
self.request = self.client.put
self.client.login(username=self.global_staff.username, password='password')
patch_get_user = mock.patch(
_VIEW_MOCK_FMT.format('get_user_by_program_id'),
autospec=True,
return_value=None
)
self.mock_get_user = patch_get_user.start()
self.addCleanup(patch_get_user.stop)
def prepare_student(self, key):
ProgramEnrollment.objects.create(
@@ -730,8 +722,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
)
url = self.get_url()
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
with _patch_get_users:
response = self.client.put(
url, json.dumps(request_data), content_type='application/json'
)
self.assertEqual(200, response.status_code)
self.assertEqual(5, len(response.data))
for response_status in response.data.values():
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
@@ -755,8 +750,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
)
url = self.get_url()
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
with _patch_get_users:
response = self.client.put(
url, json.dumps(request_data), content_type='application/json'
)
self.assertEqual(200, response.status_code)
self.assertEqual(4, len(response.data))
for response_status in response.data.values():
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
@@ -870,6 +868,7 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
)
def test_invalid_status(self):
self.prepare_student('learner-1')
request_data = [self.learner_enrollment('learner-1', 'this-is-not-a-status')]
response = self.request(self.default_url, request_data)
self.assertEqual(422, response.status_code)
@@ -885,7 +884,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
def test_422_unprocessable_entity_bad_data(self, request_data):
response = self.request(self.default_url, request_data)
self.assertEqual(response.status_code, 400)
self.assertIn('invalid enrollment record', response.data)
@ddt.data(
[{'status': 'pending'}],
@@ -897,7 +895,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
request_data.extend(bad_records)
response = self.request(self.default_url, request_data)
self.assertEqual(response.status_code, 400)
self.assertIn('invalid enrollment record', response.data)
def test_extra_field(self):
self.prepare_student('learner-1')
@@ -1190,7 +1187,7 @@ class ProgramCourseEnrollmentsModifyMixin(ProgramCourseEnrollmentsMixin):
self.assert_program_course_enrollment('learner-4', 'active', False)
class ProgramCourseEnrollmentPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
class ProgramCourseEnrollmentsPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
""" Tests for course enrollment PATCH """
def request(self, path, data, **kwargs):
@@ -1230,19 +1227,6 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
"""
view_name = 'programs_api:v1:program_course_grades'
@staticmethod
def mock_course_grade(percent=75.0, passed=True, letter_grade='B'):
return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
def test_204_no_grades_to_return(self, mock_course_grade_factory):
mock_course_grade_factory.return_value.iter.return_value = []
self.log_in_staff()
url = self.get_url(course_id=self.course_id)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(response.data['results'], [])
def test_401_if_unauthenticated(self):
url = self.get_url(course_id=self.course_id)
response = self.client.get(url)
@@ -1261,20 +1245,32 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
def test_200_grades_with_no_exceptions(self, mock_course_grade_factory):
def test_204_no_grades_to_return(self):
self.log_in_staff()
url = self.get_url(course_id=self.course_id)
with self.patch_grades_with({}):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(response.data['results'], [])
def test_200_grades_with_no_exceptions(self):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, self.mock_course_grade(), None),
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
mock_grades_by_user = {
self.student: (
self.mock_grade(),
None
),
other_student: (
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
None
),
}
self.log_in_staff()
url = self.get_url(course_id=self.course_id)
response = self.client.get(url)
with self.patch_grades_with(mock_grades_by_user):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_results = [
{
@@ -1292,20 +1288,21 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
]
self.assertEqual(response.data['results'], expected_results)
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
def test_207_grades_with_some_exceptions(self, mock_course_grade_factory):
def test_207_grades_with_some_exceptions(self):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, None, Exception('Bad Data')),
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
mock_grades_by_user = {
self.student: (None, Exception('Bad Data')),
other_student: (
self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
None,
),
}
self.log_in_staff()
url = self.get_url(course_id=self.course_id)
response = self.client.get(url)
with self.patch_grades_with(mock_grades_by_user):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
expected_results = [
{
@@ -1321,20 +1318,18 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
]
self.assertEqual(response.data['results'], expected_results)
@mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
def test_422_grades_with_only_exceptions(self, mock_course_grade_factory):
def test_422_grades_with_only_exceptions(self):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, None, Exception('Bad Data')),
(other_student, None, Exception('Timeout')),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
mock_grades_by_user = {
self.student: (None, Exception('Bad Data')),
other_student: (None, Exception('Timeout')),
}
self.log_in_staff()
url = self.get_url(course_id=self.course_id)
response = self.client.get(url)
with self.patch_grades_with(mock_grades_by_user):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
expected_results = [
{
@@ -1348,6 +1343,26 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
]
self.assertEqual(response.data['results'], expected_results)
@staticmethod
def patch_grades_with(grades_by_user):
"""
Create a patcher the CourseGradeFactory to use the `grades_by_user`
to determine the grade for each user.
Arguments:
grades_by_user: dict[User: (CourseGrade, Exception)]
"""
def patched_iter(self, users, course_key): # pylint: disable=unused-argument
return [
(user, grades_by_user[user][0], grades_by_user[user][1])
for user in users
]
return mock.patch.object(CourseGradeFactory, 'iter', new=patched_iter)
@staticmethod
def mock_grade(percent=75.0, passed=True, letter_grade='B'):
return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
@ddt.ddt
class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
@@ -1388,7 +1403,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
mock_return_value = [program for program in self.mock_program_data if program['type'] == program_type]
with mock.patch(
_VIEW_MOCK_FMT.format('get_programs_by_type'),
_VIEW_PATCH_FORMAT.format('get_programs_by_type'),
autospec=True,
return_value=mock_return_value
) as mock_get_programs_by_type:
@@ -1402,7 +1417,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
_VIEW_MOCK_FMT.format('get_programs'),
_VIEW_PATCH_FORMAT.format('get_programs'),
autospec=True,
return_value=[self.mock_program_data[0]]
) as mock_get_programs:
@@ -1421,7 +1436,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
_VIEW_MOCK_FMT.format('get_programs'),
_VIEW_PATCH_FORMAT.format('get_programs'),
autospec=True,
side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
) as mock_get_programs:
@@ -1434,7 +1449,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
mock.call(course=other_course_key),
], any_order=True)
@mock.patch(_VIEW_MOCK_FMT.format('get_programs'), autospec=True, return_value=None)
@mock.patch(_VIEW_PATCH_FORMAT.format('get_programs'), autospec=True, return_value=None)
def test_learner_200_if_no_programs_enrolled(self, mock_get_programs):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(reverse(self.view_name))
@@ -1455,7 +1470,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
self.client.login(username=self.student.username, password=self.password)
with mock.patch(
_VIEW_MOCK_FMT.format('get_programs'),
_VIEW_PATCH_FORMAT.format('get_programs'),
autospec=True,
return_value=self.mock_program_data
) as mock_get_programs:
@@ -1475,6 +1490,11 @@ class ProgramCourseEnrollmentOverviewGetTests(
"""
Tests for the ProgramCourseEnrollmentOverview view GET method.
"""
patch_resume_url = mock.patch(
_VIEW_PATCH_FORMAT.format('get_resume_urls_for_enrollments'),
autospec=True,
)
@classmethod
def setUpClass(cls):
super(ProgramCourseEnrollmentOverviewGetTests, cls).setUpClass()
@@ -1614,16 +1634,14 @@ class ProgramCourseEnrollmentOverviewGetTests(
expected_course_run_ids.add(text_type(other_course_key))
self.assertEqual(expected_course_run_ids, actual_course_run_ids)
_GET_RESUME_URL = _VIEW_MOCK_FMT.format('get_resume_urls_for_enrollments')
@mock.patch(_GET_RESUME_URL)
@patch_resume_url
def test_blank_resume_url_omitted(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
mock_get_resume_urls.return_value = {self.course_id: ''}
response = self.client.get(self.get_url(self.program_uuid))
self.assertNotIn('resume_course_run_url', response.data['course_runs'][0])
@mock.patch(_GET_RESUME_URL)
@patch_resume_url
def test_relative_resume_url_becomes_absolute(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
resume_url = '/resume-here'
@@ -1633,7 +1651,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
self.assertTrue(response_resume_url.startswith("http://testserver"))
self.assertTrue(response_resume_url.endswith(resume_url))
@mock.patch(_GET_RESUME_URL)
@patch_resume_url
def test_absolute_resume_url_stays_absolute(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
resume_url = 'http://www.resume.com/'
@@ -1968,6 +1986,10 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
reset_enrollments_cmd = 'reset_enrollment_data'
reset_users_cmd = 'remove_social_auth_users'
patch_call_command = mock.patch(
_VIEW_PATCH_FORMAT.format('call_command'), autospec=True
)
def setUp(self):
super(EnrollmentDataResetViewTests, self).setUp()
self.start_cache_isolation()
@@ -1989,14 +2011,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
self.end_cache_isolation()
super(EnrollmentDataResetViewTests, self).tearDown()
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_feature_disabled_by_default(self, mock_call_command):
response = self.request(self.organization.short_name)
self.assertEqual(response.status_code, status.HTTP_501_NOT_IMPLEMENTED)
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_403_for_non_staff(self, mock_call_command):
student = UserFactory.create(username='student', password='password')
self.client.login(username=student.username, password='password')
@@ -2005,7 +2027,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_reset(self, mock_call_command):
programs = [str(uuid4()), str(uuid4())]
self.set_org_in_catalog_cache(self.organization, programs)
@@ -2018,7 +2040,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_reset_without_idp(self, mock_call_command):
organization = LMSOrganizationFactory()
programs = [str(uuid4()), str(uuid4())]
@@ -2031,14 +2053,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_organization_not_found(self, mock_call_command):
response = self.request('yyz')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_no_programs_doesnt_break(self, mock_call_command):
programs = []
self.set_org_in_catalog_cache(self.organization, programs)
@@ -2050,7 +2072,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
@patch_call_command
def test_missing_body_content(self, mock_call_command):
response = self.client.post(
reverse('programs_api:v1:reset_enrollment_data'),

View File

@@ -12,7 +12,6 @@ from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination
from openedx.core.djangoapps.catalog.utils import get_programs, is_course_run_in_program
from openedx.core.lib.api.view_utils import verify_course_exists
@@ -116,6 +115,28 @@ def verify_course_exists_and_in_program(view_func):
return wrapped_function
def get_enrollment_http_code(result_statuses, ok_statuses):
"""
Given a set of enrollment create/update statuses,
return the appropriate HTTP status code.
Arguments:
result_statuses (sequence[str]): set of enrollment operation statuses
(for example, 'enrolled', 'not-in-program', etc.)
ok_statuses: sequence[str]: set of 'OK' (non-error) statuses
"""
result_status_set = set(result_statuses)
ok_status_set = set(ok_statuses)
if not result_status_set:
return status.HTTP_204_NO_CONTENT
if result_status_set.issubset(ok_status_set):
return status.HTTP_200_OK
elif result_status_set & ok_status_set:
return status.HTTP_207_MULTI_STATUS
else:
return status.HTTP_422_UNPROCESSABLE_ENTITY
def get_course_run_status(course_overview, certificate_info):
"""
Get the progress status of a course run, given the state of a user's

View File

@@ -4,8 +4,6 @@ ProgramEnrollment Views
"""
from __future__ import absolute_import, unicode_literals
import logging
from ccx_keys.locator import CCXLocator
from django.conf import settings
from django.core.exceptions import PermissionDenied
@@ -17,29 +15,30 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut
from opaque_keys.edx.keys import CourseKey
from organizations.models import Organization
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from six import text_type
from course_modes.models import CourseMode
from lms.djangoapps.bulk_email.api import get_emails_enabled
from lms.djangoapps.certificates.api import get_certificate_for_user
from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
from lms.djangoapps.program_enrollments.api import (
fetch_program_course_enrollments,
fetch_program_enrollments,
fetch_program_enrollments_by_student
)
from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.utils import (
ProviderDoesNotExistException,
fetch_program_enrollments_by_student,
get_provider_slug,
get_user_by_program_id
get_saml_provider_for_organization,
iter_program_course_grades,
write_program_course_enrollments,
write_program_enrollments
)
from lms.djangoapps.program_enrollments.constants import (
ProgramCourseOperationStatuses,
ProgramEnrollmentStatuses,
ProgramOperationStatuses
)
from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
from openedx.core.djangoapps.catalog.utils import (
course_run_keys_for_program,
get_programs,
@@ -55,38 +54,89 @@ from student.models import CourseEnrollment
from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole
from util.query import read_replica_or_default
from .constants import (
ENABLE_ENROLLMENT_RESET_FLAG,
MAX_ENROLLMENT_RECORDS,
ProgramCourseResponseStatuses,
ProgramResponseStatuses
)
from .constants import ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS
from .serializers import (
CourseRunOverviewListSerializer,
ProgramCourseEnrollmentRequestSerializer,
ProgramCourseEnrollmentSerializer,
ProgramCourseGradeError,
ProgramCourseGradeOk,
ProgramCourseGradeSerializer,
ProgramEnrollmentCreateRequestSerializer,
ProgramEnrollmentModifyRequestSerializer,
ProgramEnrollmentSerializer
ProgramEnrollmentSerializer,
ProgramEnrollmentUpdateRequestSerializer
)
from .utils import (
ProgramCourseSpecificViewMixin,
ProgramEnrollmentPagination,
ProgramSpecificViewMixin,
get_course_run_status,
get_enrollment_http_code,
verify_course_exists_and_in_program,
verify_program_exists
)
logger = logging.getLogger(__name__)
class EnrollmentWriteMixin(object):
"""
Common functionality for viewsets with enrollment-writing POST/PATCH/PUT methods.
Provides a `handle_write_request` utility method, which depends on the
definitions of `serializer_class_by_write_method`, `ok_write_statuses`,
and `perform_enrollment_write`.
"""
create_update_by_write_method = {
'POST': (True, False),
'PATCH': (False, True),
'PUT': (True, True),
}
# Set in subclasses
serializer_class_by_write_method = "set-me-to-a-dict-with-http-method-keys"
ok_write_statuses = "set-me-to-a-set"
def handle_write_request(self):
"""
Create/modify program enrollments.
Returns: Response
"""
serializer_class = self.serializer_class_by_write_method[self.request.method]
serializer = serializer_class(data=self.request.data, many=True)
serializer.is_valid(raise_exception=True)
num_requests = len(self.request.data)
if num_requests > MAX_ENROLLMENT_RECORDS:
return Response(
'{} enrollments requested, but limit is {}.'.format(
MAX_ENROLLMENT_RECORDS, num_requests
),
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
create, update = self.create_update_by_write_method[self.request.method]
results = self.perform_enrollment_write(
serializer.validated_data, create, update
)
http_code = get_enrollment_http_code(
results.values(), self.ok_write_statuses
)
return Response(status=http_code, data=results, content_type='application/json')
def perform_enrollment_write(self, enrollment_requests, create, update):
"""
Perform the write operation. Implemented in subclasses.
Arguments:
enrollment_requests: list[dict]
create (bool)
update (bool)
Returns: dict[str: str]
Map from external keys to enrollment write statuses.
"""
raise NotImplementedError()
class ProgramEnrollmentsView(
EnrollmentWriteMixin,
DeveloperErrorViewMixin,
ProgramCourseSpecificViewMixin,
ProgramSpecificViewMixin,
PaginatedAPIView,
):
"""
@@ -180,7 +230,7 @@ class ProgramEnrollmentsView(
* 'duplicated' - the request body listed the same learner twice
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
* 201: CREATED - All students were successfully enrolled.
* 200: OK - All students were successfully enrolled.
* Example json response:
{
'123': 'enrolled',
@@ -244,7 +294,7 @@ class ProgramEnrollmentsView(
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
* 'not-in-program' - the user is not in the program and cannot be updated
* 201: CREATED - All students were successfully enrolled.
* 200: OK - All students were successfully enrolled.
* Example json response:
{
'123': 'enrolled',
@@ -274,187 +324,65 @@ class ProgramEnrollmentsView(
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
# Overridden from `EnrollmentWriteMixin`
serializer_class_by_write_method = {
'POST': ProgramEnrollmentCreateRequestSerializer,
'PATCH': ProgramEnrollmentUpdateRequestSerializer,
'PUT': ProgramEnrollmentCreateRequestSerializer,
}
ok_write_statuses = ProgramOperationStatuses.__OK__
@verify_program_exists
def get(self, request, program_uuid=None):
""" Defines the GET list endpoint for ProgramEnrollment objects. """
enrollments = fetch_program_enrollments(
program_uuid
self.program_uuid
).using(read_replica_or_default())
paginated_enrollments = self.paginate_queryset(enrollments)
serializer = ProgramEnrollmentSerializer(paginated_enrollments, many=True)
return self.get_paginated_response(serializer.data)
@verify_program_exists
def post(self, request, *args, **kwargs):
def post(self, request, program_uuid=None):
"""
Create program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentCreateRequestSerializer,
self.create_program_enrollment,
status.HTTP_201_CREATED,
)
return self.handle_write_request()
@verify_program_exists
def patch(self, request, **kwargs):
def patch(self, request, program_uuid=None): # pylint: disable=unused-argument
"""
Modify program enrollments for a list of learners
Update program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentModifyRequestSerializer,
self.modify_program_enrollment,
status.HTTP_200_OK,
)
return self.handle_write_request()
@verify_program_exists
def put(self, request, **kwargs):
def put(self, request, program_uuid=None): # pylint: disable=unused-argument
"""
Create/modify program enrollments for a list of learners
Create/update program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentCreateRequestSerializer,
self.create_or_modify_program_enrollment,
status.HTTP_200_OK,
)
return self.handle_write_request()
def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
def perform_enrollment_write(self, enrollment_requests, create, update):
"""
Validates the given enrollment record and checks that it isn't a duplicate
Perform the program enrollment write operation.
Overridden from `EnrollmentWriteMixin`.
Arguments:
enrollment_requests: list[dict]
create (bool)
update (bool)
Returns: dict[str: str]
Map from external keys to enrollment write statuses.
"""
student_key = enrollment['student_key']
if student_key in seen_student_keys:
return ProgramResponseStatuses.DUPLICATED
seen_student_keys.add(student_key)
enrollment_serializer = serializer_class(data=enrollment)
try:
enrollment_serializer.is_valid(raise_exception=True)
except ValidationError:
if enrollment_serializer.has_invalid_status():
return ProgramResponseStatuses.INVALID_STATUS
else:
raise
def create_or_modify_enrollments(self, request, program_uuid, serializer_class, operation, success_status):
"""
Process a list of program course enrollment request objects
and create or modify enrollments based on method
"""
results = {}
seen_student_keys = set()
enrollments = []
if not isinstance(request.data, list):
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
if len(request.data) > MAX_ENROLLMENT_RECORDS:
return Response(
'enrollment limit {}'.format(MAX_ENROLLMENT_RECORDS),
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
try:
for enrollment_request in request.data:
error_status = self.validate_enrollment_request(enrollment_request, seen_student_keys, serializer_class)
if error_status:
results[enrollment_request["student_key"]] = error_status
else:
enrollments.append(enrollment_request)
except KeyError: # student_key is not in enrollment_request
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
except TypeError: # enrollment_request isn't a dict
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
except ValidationError: # there was some other error raised by the serializer
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
for enrollment in enrollments:
student_key = enrollment["student_key"]
if student_key in results and results[student_key] == ProgramResponseStatuses.DUPLICATED:
continue
try:
program_enrollment = program_enrollments[student_key]
except KeyError:
program_enrollment = None
results[student_key] = operation(enrollment, program_uuid, program_enrollment)
return self._get_created_or_updated_response(results, success_status)
def create_program_enrollment(self, request_data, program_uuid, program_enrollment):
"""
Create new ProgramEnrollment, unless the learner is already enrolled in the program
"""
if program_enrollment:
return ProgramResponseStatuses.CONFLICT
student_key = request_data.get('student_key')
try:
user = get_user_by_program_id(student_key, program_uuid)
except ProviderDoesNotExistException:
# IDP has not yet been set up, just create waiting enrollments
user = None
enrollment = ProgramEnrollment.objects.create(
user=user,
external_user_key=student_key,
program_uuid=program_uuid,
curriculum_uuid=request_data.get('curriculum_uuid'),
status=request_data.get('status')
)
return enrollment.status
# pylint: disable=unused-argument
def modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
"""
Change the status of an existing program enrollment
"""
if not program_enrollment:
return ProgramResponseStatuses.NOT_IN_PROGRAM
program_enrollment.status = request_data.get('status')
program_enrollment.save()
return program_enrollment.status
def create_or_modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
if program_enrollment:
return self.modify_program_enrollment(request_data, program_uuid, program_enrollment)
else:
return self.create_program_enrollment(request_data, program_uuid, program_enrollment)
def get_existing_program_enrollments(self, program_uuid, student_data):
""" Returns the existing program enrollments for the given students and program """
student_keys = [data['student_key'] for data in student_data]
program_enrollments_qs = fetch_program_enrollments(
program_uuid=program_uuid, external_user_keys=student_keys
)
return {e.external_user_key: e for e in program_enrollments_qs}
def _get_created_or_updated_response(self, response_data, default_status=status.HTTP_201_CREATED):
"""
Helper method to determine an appropirate HTTP response status code.
"""
response_status = default_status
good_count = len([
v for v in response_data.values()
if v in ProgramResponseStatuses.__OK__
])
if not good_count:
response_status = status.HTTP_422_UNPROCESSABLE_ENTITY
elif good_count != len(response_data):
response_status = status.HTTP_207_MULTI_STATUS
return Response(
status=response_status,
data=response_data,
content_type='application/json',
return write_program_enrollments(
self.program_uuid, enrollment_requests, create=create, update=update
)
class ProgramCourseEnrollmentsView(
EnrollmentWriteMixin,
DeveloperErrorViewMixin,
ProgramCourseSpecificViewMixin,
PaginatedAPIView,
@@ -540,6 +468,14 @@ class ProgramCourseEnrollmentsView(
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
# Overridden from `EnrollmentWriteMixin`
serializer_class_by_write_method = {
'POST': ProgramCourseEnrollmentRequestSerializer,
'PATCH': ProgramCourseEnrollmentRequestSerializer,
'PUT': ProgramCourseEnrollmentRequestSerializer,
}
ok_write_statuses = ProgramCourseOperationStatuses.__OK__
@verify_course_exists_and_in_program
def get(self, request, program_uuid=None, course_id=None):
"""
@@ -559,11 +495,7 @@ class ProgramCourseEnrollmentsView(
"""
Enroll a list of students in a course in a program
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.enroll_learner_in_course
)
return self.handle_write_request()
@verify_course_exists_and_in_program
# pylint: disable=unused-argument
@@ -571,11 +503,7 @@ class ProgramCourseEnrollmentsView(
"""
Modify the program course enrollments of a list of learners
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.modify_learner_enrollment_status
)
return self.handle_write_request()
@verify_course_exists_and_in_program
# pylint: disable=unused-argument
@@ -583,140 +511,29 @@ class ProgramCourseEnrollmentsView(
"""
Create or Update the program course enrollments of a list of learners
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.create_or_update_learner_enrollment
)
return self.handle_write_request()
def create_or_modify_enrollments(self, request, program_uuid, operation):
def perform_enrollment_write(self, enrollment_requests, create, update):
"""
Process a list of program course enrollment request objects
and create or modify enrollments based on method
Perform the program enrollment write operation.
Overridden from `EnrollmentWriteMixin`.
Arguments:
enrollment_requests: list[dict]
create (bool)
update (bool)
Returns: dict[str: str]
Map from external keys to enrollment write statuses.
"""
results = {}
seen_student_keys = set()
enrollments = []
if not isinstance(request.data, list):
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
if len(request.data) > MAX_ENROLLMENT_RECORDS:
return Response(
'enrollment limit 25', status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
try:
for enrollment_request in request.data:
error_status = self.check_enrollment_request(enrollment_request, seen_student_keys)
if error_status:
results[enrollment_request["student_key"]] = error_status
else:
enrollments.append(enrollment_request)
except KeyError: # student_key is not in enrollment_request
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
except TypeError: # enrollment_request isn't a dict
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
except ValidationError: # there was some other error raised by the serializer
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
for enrollment in enrollments:
student_key = enrollment["student_key"]
if student_key in results and results[student_key] == ProgramCourseResponseStatuses.DUPLICATED:
continue
try:
program_enrollment = program_enrollments[student_key]
except KeyError:
results[student_key] = ProgramCourseResponseStatuses.NOT_IN_PROGRAM
else:
program_course_enrollment = program_enrollment.get_program_course_enrollment(self.course_key)
results[student_key] = operation(enrollment, program_enrollment, program_course_enrollment)
good_count = sum(
1 for _, v in results.items()
if v in ProgramCourseResponseStatuses.__OK__
)
if not good_count:
return Response(results, status.HTTP_422_UNPROCESSABLE_ENTITY)
if good_count != len(results):
return Response(results, status.HTTP_207_MULTI_STATUS)
else:
return Response(results)
def check_enrollment_request(self, enrollment, seen_student_keys):
"""
Checks that the given enrollment record is valid and hasn't been duplicated
"""
student_key = enrollment['student_key']
if student_key in seen_student_keys:
return ProgramCourseResponseStatuses.DUPLICATED
seen_student_keys.add(student_key)
enrollment_serializer = ProgramCourseEnrollmentRequestSerializer(data=enrollment)
try:
enrollment_serializer.is_valid(raise_exception=True)
except ValidationError as e:
if enrollment_serializer.has_invalid_status():
return ProgramCourseResponseStatuses.INVALID_STATUS
else:
raise e
def get_existing_program_enrollments(self, program_uuid, enrollments):
"""
Parameters:
- enrollments: A list of enrollment requests
Returns:
- Dictionary mapping all student keys in the enrollment requests
to that user's existing program enrollment in <self.program>
"""
external_user_keys = [e["student_key"] for e in enrollments]
existing_enrollments = fetch_program_enrollments(
program_uuid=program_uuid,
external_user_keys=external_user_keys,
).prefetch_related('program_course_enrollments')
return {enrollment.external_user_key: enrollment for enrollment in existing_enrollments}
def enroll_learner_in_course(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to enroll the specified user into the course as a part of the
given program enrollment with the given status
Returns the actual status
"""
if program_course_enrollment:
return ProgramCourseResponseStatuses.CONFLICT
return ProgramCourseEnrollment.create_program_course_enrollment(
program_enrollment,
return write_program_course_enrollments(
self.program_uuid,
self.course_key,
enrollment_request['status']
enrollment_requests,
create=create,
update=update,
)
# pylint: disable=unused-argument
def modify_learner_enrollment_status(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to modify the specified user's enrollment in the given course
in the given program
"""
if program_course_enrollment is None:
return ProgramCourseResponseStatuses.NOT_FOUND
return program_course_enrollment.change_status(enrollment_request['status'])
def create_or_update_learner_enrollment(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to create or update the specified user's enrollment in the given course
in the given program
"""
if program_course_enrollment is None:
# create the course enrollment
return ProgramCourseEnrollment.create_program_course_enrollment(
program_enrollment,
self.course_key,
enrollment_request['status']
)
else:
# Update course enrollment
return program_course_enrollment.change_status(enrollment_request['status'])
class ProgramCourseGradesView(
DeveloperErrorViewMixin,
@@ -798,83 +615,13 @@ class ProgramCourseGradesView(
"""
Defines the GET list endpoint for ProgramCourseGrade objects.
"""
course_key = CourseKey.from_string(course_id)
grade_results = self._load_grade_results(program_uuid, course_key)
grade_results = list(iter_program_course_grades(
self.program_uuid, self.course_key, self.paginate_queryset
))
serializer = ProgramCourseGradeSerializer(grade_results, many=True)
response_code = self._calc_response_code(grade_results)
return self.get_paginated_response(serializer.data, status_code=response_code)
def _load_grade_results(self, program_uuid, course_key):
"""
Load grades (or grading errors) for a given program courserun.
Arguments:
program_uuid (str)
course_key (CourseKey)
Returns: list[BaseProgramCourseGrade]
"""
enrollments_qs = fetch_program_course_enrollments(
program_uuid=program_uuid,
course_key=course_key,
realized_only=True,
).select_related(
'program_enrollment',
'program_enrollment__user',
).using(read_replica_or_default())
paginated_enrollments = self.paginate_queryset(enrollments_qs)
if not paginated_enrollments:
return []
# Hint: `zip(*(list))` can be read as "unzip(list)"
enrollments, users = zip(*(
(enrollment, enrollment.program_enrollment.user)
for enrollment in paginated_enrollments
))
enrollment_grade_pairs = zip(
enrollments, self._iter_grades(course_key, list(users))
)
grade_results = [
(
ProgramCourseGradeOk(enrollment, grade)
if grade
else ProgramCourseGradeError(enrollment, exception)
)
for enrollment, (grade, exception) in enrollment_grade_pairs
]
return grade_results
@staticmethod
def _iter_grades(course_key, users):
"""
Load a user grades for a course, using bulk fetching for efficiency.
Arguments:
course_key (CourseKey)
users (list[User])
Returns: iterable[( CourseGradeBase|NoneType, Exception|NoneType )]
Iterable of pairs, in same order as `users`.
The first item in the pair is the grade, or None if loading the
grade failed.
The second item in the pair is an exception or None.
"""
prefetch_course_grades(course_key, users)
try:
grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
for user, course_grade, exception in grades_iter:
if not course_grade:
fmt = 'Failed to load course grade for user ID {} in {}: {}'
err_str = fmt.format(
user.id,
course_key,
text_type(exception) if exception else 'Unknown error'
)
logger.error(err_str)
yield course_grade, exception
finally:
clear_prefetched_course_grades(course_key)
@staticmethod
def _calc_response_code(grade_results):
"""
@@ -1250,10 +997,12 @@ class EnrollmentDataResetView(APIView):
return Response('organization {} not found'.format(org_key), status.HTTP_404_NOT_FOUND)
try:
idp_slug = get_provider_slug(organization)
call_command('remove_social_auth_users', idp_slug, force=True)
provider = get_saml_provider_for_organization(organization)
except ProviderDoesNotExistException:
pass
else:
idp_slug = get_provider_slug(provider)
call_command('remove_social_auth_users', idp_slug, force=True)
programs = get_programs_for_organization(organization=organization.short_name)
if programs:

View File

@@ -11,10 +11,9 @@ from social_django.models import UserSocialAuth
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_MISC
from student.models import CourseEnrollmentException
from third_party_auth.models import SAMLProviderConfig
from .api import fetch_program_enrollments_by_student
from .api import fetch_program_enrollments_by_student, link_program_enrollment_to_lms_user
from .models import ProgramEnrollment
logger = logging.getLogger(__name__)
@@ -95,17 +94,4 @@ def matriculate_learner(user, uid):
authorizing_org.short_name
)
continue
enrollment.user = user
enrollment.save()
for program_course_enrollment in enrollment.program_course_enrollments.all():
try:
program_course_enrollment.enroll(user)
except CourseEnrollmentException as e:
logger.warning(
'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
user.id,
program_course_enrollment.id,
e,
)
raise e
link_program_enrollment_to_lms_user(enrollment, user)

View File

@@ -6,18 +6,15 @@ from __future__ import absolute_import, unicode_literals
from uuid import uuid4
import ddt
import mock
from django.db.utils import IntegrityError
from django.test import TestCase
from edx_django_utils.cache import RequestCache
from opaque_keys.edx.keys import CourseKey
from testfixtures import LogCapture
from course_modes.models import CourseMode
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from openedx.core.djangoapps.catalog.tests.factories import generate_course_run_key
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.models import CourseEnrollment, NonExistentCourseError
from student.tests.factories import CourseEnrollmentFactory, UserFactory
@@ -173,89 +170,3 @@ class ProgramCourseEnrollmentModelTests(TestCase):
course_enrollment=None,
status="active"
)
def test_change_status_no_enrollment(self):
program_course_enrollment = self._create_completed_program_course_enrollment()
with LogCapture() as capture:
program_course_enrollment.course_enrollment = None
program_course_enrollment.change_status("inactive")
expected_message = "User {} {} {} has no course_enrollment".format(
self.user,
self.program_enrollment,
self.course_key
)
capture.check(
('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
)
def test_change_status_not_active_or_inactive(self):
program_course_enrollment = self._create_completed_program_course_enrollment()
with LogCapture() as capture:
status = "potential-future-status-0123"
program_course_enrollment.change_status(status)
message = ("Changed {} status to {}, not changing course_enrollment"
" status because status is not 'active' or 'inactive'")
expected_message = message.format(program_course_enrollment, status)
capture.check(
('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
)
def test_enroll_new_course_enrollment(self):
program_course_enrollment = self._create_waiting_program_course_enrollment()
program_course_enrollment.enroll(self.user)
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
self.assertEqual(course_enrollment.user, self.user)
self.assertEqual(course_enrollment.course.id, self.course_key)
self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
def test_enrollment_course_not_found(self):
nonexistent_key = 'course-v1:edX+Overview+DNE'
program_course_enrollment = ProgramCourseEnrollment.objects.create(
program_enrollment=self.program_enrollment,
course_key=nonexistent_key,
course_enrollment=None,
status="active"
)
with LogCapture() as capture:
with self.assertRaises(NonExistentCourseError):
program_course_enrollment.enroll(self.user)
expected = "User {} failed to enroll in non-existent course {}".format(
self.user.id, nonexistent_key
)
capture.check(
('lms.djangoapps.program_enrollments.models', 'WARNING', expected)
)
@ddt.data(
(CourseMode.VERIFIED, CourseMode.VERIFIED),
(CourseMode.AUDIT, CourseMode.MASTERS),
(CourseMode.HONOR, CourseMode.MASTERS)
)
@ddt.unpack
def test_enroll_existing_course_enrollment(self, original_mode, result_mode):
course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_key,
user=self.user,
mode=original_mode
)
program_course_enrollment = self._create_waiting_program_course_enrollment()
program_course_enrollment.enroll(self.user)
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
self.assertEqual(course_enrollment.user, self.user)
self.assertEqual(course_enrollment.course.id, self.course_key)
self.assertEqual(course_enrollment.mode, result_mode)
@mock.patch('student.models.CourseEnrollment.is_enrollment_closed', return_value=True)
def test_closed_enrollments_ignored(self, _mock):
""" enrolling through program enrollments should ignore checks on enrollment """
program_course_enrollment = self._create_waiting_program_course_enrollment()
program_course_enrollment.enroll(self.user)
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
self.assertEqual(course_enrollment.user, self.user)
self.assertEqual(course_enrollment.course.id, self.course_key)
self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)

View File

@@ -341,27 +341,16 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
)
)
def test_log_on_enrollment_failure(self):
def test_exception_on_enrollment_failure(self):
program_enrollment = self._create_waiting_program_enrollment()
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
self._create_waiting_course_enrollments(program_enrollment)
with mock.patch('student.models.CourseEnrollment.enroll') as enrollMock:
enrollMock.side_effect = CourseEnrollmentException('something has gone wrong')
with LogCapture(logger.name) as log:
with pytest.raises(CourseEnrollmentException):
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
error_template = 'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
log.check_present(
(
logger.name,
'WARNING',
error_template.format(
self.user.id, program_course_enrollments[0].id, 'something has gone wrong'
)
)
with pytest.raises(CourseEnrollmentException):
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
def test_log_on_unexpected_exception(self):
@@ -371,7 +360,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
program_enrollment = self._create_waiting_program_enrollment()
self._create_waiting_course_enrollments(program_enrollment)
with mock.patch('lms.djangoapps.program_enrollments.models.ProgramCourseEnrollment.enroll') as enrollMock:
with mock.patch('lms.djangoapps.program_enrollments.api.linking.enroll_in_masters_track') as enrollMock:
enrollMock.side_effect = Exception('unexpected error')
with LogCapture(logger.name) as log:
with self.assertRaisesRegex(Exception, 'unexpected error'):

View File

@@ -1,157 +0,0 @@
"""
Unit tests for program_enrollments utils.
"""
from __future__ import absolute_import, unicode_literals
from uuid import uuid4
import ddt
import pytest
from django.core.cache import cache
from organizations.tests.factories import OrganizationFactory
from social_django.models import UserSocialAuth
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from program_enrollments.utils import (
OrganizationDoesNotExistException,
ProgramDoesNotExistException,
ProviderConfigurationException,
ProviderDoesNotExistException,
get_user_by_program_id
)
from student.tests.factories import UserFactory
from third_party_auth.tests.factories import SAMLProviderConfigFactory
@ddt.ddt
class GetPlatformUserTests(CacheIsolationTestCase):
"""
Tests for the get_platform_user function
"""
ENABLED_CACHES = ['default']
def setUp(self):
super(GetPlatformUserTests, self).setUp()
self.program_uuid = uuid4()
self.organization_key = 'ufo'
self.external_user_id = '1234'
self.user = UserFactory.create()
self.setup_catalog_cache(self.program_uuid, self.organization_key)
def setup_catalog_cache(self, program_uuid, organization_key):
"""
helper function to initialize a cached program with an single authoring_organization
"""
catalog_org = CatalogOrganizationFactory.create(key=organization_key)
program = ProgramFactory.create(
uuid=program_uuid,
authoring_organizations=[catalog_org]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
def create_social_auth_entry(self, user, provider, external_id):
"""
helper functio to create a user social auth entry
"""
UserSocialAuth.objects.create(
user=user,
uid='{0}:{1}'.format(provider.slug, external_id)
)
def test_get_user_success(self):
"""
Test lms user is successfully found
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
provider = SAMLProviderConfigFactory.create(organization=organization)
self.create_social_auth_entry(self.user, provider, self.external_user_id)
user = get_user_by_program_id(self.external_user_id, self.program_uuid)
self.assertEquals(user, self.user)
def test_social_auth_user_not_created(self):
"""
None should be returned if no lms user exists for an external id
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
SAMLProviderConfigFactory.create(organization=organization)
user = get_user_by_program_id(self.external_user_id, self.program_uuid)
self.assertIsNone(user)
def test_catalog_program_does_not_exist(self):
"""
Test ProgramDoesNotExistException is thrown if the program cache does
not include the requested program uuid.
"""
with pytest.raises(ProgramDoesNotExistException):
get_user_by_program_id('school-id-1234', uuid4())
def test_catalog_program_missing_org(self):
"""
Test OrganizationDoesNotExistException is thrown if the cached program does not
have an authoring organization.
"""
program = ProgramFactory.create(
uuid=self.program_uuid,
authoring_organizations=[]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
organization = OrganizationFactory.create(short_name=self.organization_key)
provider = SAMLProviderConfigFactory.create(organization=organization)
self.create_social_auth_entry(self.user, provider, self.external_user_id)
with pytest.raises(OrganizationDoesNotExistException):
get_user_by_program_id(self.external_user_id, self.program_uuid)
def test_lms_organization_not_found(self):
"""
Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
matching the catalog program's authoring_organization
"""
organization = OrganizationFactory.create(short_name='some_other_org')
provider = SAMLProviderConfigFactory.create(organization=organization)
self.create_social_auth_entry(self.user, provider, self.external_user_id)
with pytest.raises(OrganizationDoesNotExistException):
get_user_by_program_id(self.external_user_id, self.program_uuid)
def test_saml_provider_not_found(self):
"""
Test an exception is thrown if no SAML provider exists for this program's organization
"""
OrganizationFactory.create(short_name=self.organization_key)
with pytest.raises(ProviderDoesNotExistException):
get_user_by_program_id(self.external_user_id, self.program_uuid)
@ddt.data(True, False)
def test_multiple_saml_providers(self, second_config_enabled):
"""
If multiple samlprovider records exist with the same organization
an exception is raised
"""
organization = OrganizationFactory.create(short_name=self.organization_key)
provider = SAMLProviderConfigFactory.create(organization=organization)
self.create_social_auth_entry(self.user, provider, self.external_user_id)
# create a second active config for the same organization
SAMLProviderConfigFactory.create(
organization=organization, slug='foox', enabled=second_config_enabled
)
try:
get_user_by_program_id(self.external_user_id, self.program_uuid)
except ProviderConfigurationException:
self.assertTrue(
second_config_enabled, 'Unexpected error when second config is disabled'
)
else:
self.assertFalse(
second_config_enabled, 'Expected error was not raised when second config is enabled'
)

View File

@@ -1,117 +0,0 @@
"""
utility functions for program enrollments
"""
from __future__ import absolute_import, unicode_literals
import logging
from organizations.models import Organization
from social_django.models import UserSocialAuth
from openedx.core.djangoapps.catalog.utils import get_programs
from third_party_auth.models import SAMLProviderConfig
log = logging.getLogger(__name__)
class ProgramDoesNotExistException(Exception):
pass
class OrganizationDoesNotExistException(Exception):
pass
class ProviderDoesNotExistException(Exception):
pass
class ProviderConfigurationException(Exception):
pass
def get_user_by_program_id(external_user_id, program_uuid):
"""
Returns a User model for an external_user_id with a social auth entry.
Args:
external_user_id: external user id used for social auth
program_uuid: a program this user is/will be enrolled in
Returns:
A User object or None, if no user with the given external id for the given organization
exists.
Raises:
ProgramDoesNotExistException if no such program exists.
OrganizationDoesNotExistException if no organization exists.
ProviderDoesNotExistException if there is no SAML provider configured for the related
organization.
"""
program = get_programs(uuid=program_uuid)
if program is None:
log.error(u'Unable to find catalog program matching uuid [%s]', program_uuid)
raise ProgramDoesNotExistException
try:
org_key = program['authoring_organizations'][0]['key']
organization = Organization.objects.get(short_name=org_key)
except (KeyError, IndexError):
log.error(
u'Cannot determine authoring organization key for catalog program [%s]', program_uuid
)
raise OrganizationDoesNotExistException
except Organization.DoesNotExist:
log.error(u'Unable to find organization for short_name [%s]', org_key)
raise OrganizationDoesNotExistException
return get_user_by_organization(external_user_id, organization)
def get_user_by_organization(external_user_id, organization):
"""
Returns a User model for an external_user_id with a social auth entry.
This function finds a matching SAML Provider for the given organization, and looks
for a social auth entry with the provided exernal id.
Args:
external_user_id: external user id used for social auth
organization: organization providing saml authentication for this user
Returns:
A User object or None, if no user with the given external id for the given organization
exists.
Raises:
ProviderDoesNotExistException if there is no SAML provider configured for the related
organization.
"""
provider_slug = get_provider_slug(organization)
try:
social_auth_uid = '{0}:{1}'.format(provider_slug, external_user_id)
return UserSocialAuth.objects.get(uid=social_auth_uid).user
except UserSocialAuth.DoesNotExist:
return None
def get_provider_slug(organization):
"""
Returns slug for the currently configured saml provder on an Organization
Raises:
ProviderDoesNotExistsException
ProviderConfigurationException
"""
try:
provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
except SAMLProviderConfig.DoesNotExist:
log.error('No SAML provider found for organization id [%s]', organization.id)
raise ProviderDoesNotExistException
except SAMLProviderConfig.MultipleObjectsReturned:
log.error(
'Multiple active SAML configurations found for organization=%s. Expected one.',
organization.short_name,
)
raise ProviderConfigurationException
return provider_config.provider_id.strip('saml-')

View File

@@ -9,12 +9,13 @@ import itertools
import json
import re
from datetime import datetime, timedelta
from uuid import uuid4
from uuid import uuid4, UUID
import ddt
import six
from django.contrib.auth.models import User
from django.db.models import signals
from django.http import HttpResponse
from django.urls import reverse
from mock import patch
from pytz import UTC
@@ -22,7 +23,6 @@ from pytz import UTC
from common.test.utils import disable_signal
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from lms.djangoapps.program_enrollments.api import NO_PROGRAM_ENROLLMENT_TEMPLATE
from lms.djangoapps.verify_student.models import VerificationDeadline
from student.models import ENROLLED_TO_ENROLLED, CourseEnrollment, CourseEnrollmentAttribute, ManualEnrollmentAudit
from student.roles import GlobalStaff, SupportStaffRole
@@ -446,6 +446,12 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
"""
Tests for the link_program_enrollments support view.
"""
patch_render = patch(
'support.views.program_enrollments.render_to_response',
return_value=HttpResponse(),
autospec=True,
)
def setUp(self):
"""Make the user support staff. """
super(SupportViewLinkProgramEnrollmentsTests, self).setUp()
@@ -454,53 +460,62 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
self.program_uuid = str(uuid4())
self.text = '0001,user-0001\n0002,user-02'
def _assert_props(self, field_name, value, response):
self.assertIn('"{}": "{}"'.format(field_name, value), six.text_type(response.content, encoding='utf-8'))
@patch_render
def test_get(self, mocked_render):
self.client.get(self.url)
render_call_dict = mocked_render.call_args[0][1]
assert render_call_dict == {
'successes': [],
'errors': [],
'program_uuid': '',
'text': ''
}
def _assert_props_list(self, field_name, values, response):
def test_rendering(self):
"""
Assert that that page is being rendered with a specific list of props
Test the view without mocking out the rendering like the rest of the tests.
"""
values_str = ''
if values:
values_str = '", "'.join(values)
values_str = '"{}"'.format(values_str)
self.assertIn(u'"{}": [{}]'.format(field_name, values_str), six.text_type(response.content, encoding='utf-8'))
def test_get(self):
response = self.client.get(self.url)
self._assert_props_list('successes', [], response)
self._assert_props_list('errors', [], response)
self._assert_props('programUUID', '', response)
self._assert_props('text', '', response)
content = six.text_type(response.content, encoding='utf-8')
assert '"programUUID": ""' in content
assert '"text": ""' in content
def test_invalid_uuid(self):
response = self.client.post(self.url, data={
@patch_render
def test_invalid_uuid(self, mocked_render):
self.client.post(self.url, data={
'program_uuid': 'notauuid',
'text': self.text,
})
self._assert_props_list('errors', [u'badly formed hexadecimal UUID string'], response)
msg = u"Supplied program UUID 'notauuid' is not a valid UUID."
render_call_dict = mocked_render.call_args[0][1]
assert render_call_dict['errors'] == [msg]
@ddt.unpack
@patch_render
@ddt.data(
('program_uuid', ''),
('', 'text'),
('', ''),
)
def test_missing_parameter(self, program_uuid, text):
msg = u'You must provide both a program uuid and a comma separated list of external_student_key, username'
response = self.client.post(self.url, data={
@ddt.unpack
def test_missing_parameter(self, program_uuid, text, mocked_render):
error = (
u"You must provide both a program uuid "
u"and a series of lines with the format "
u"'external_user_key,lms_username'."
)
self.client.post(self.url, data={
'program_uuid': program_uuid,
'text': text,
})
self._assert_props_list('errors', [msg], response)
render_call_dict = mocked_render.call_args[0][1]
assert render_call_dict['errors'] == [error]
@ddt.data(
'0001,learner-01\n0002,learner-02', # normal
'0001,learner-01,apple,orange\n0002,learner-02,purple', # extra fields
'\t0001 , \t learner-01 \n 0002 , learner-02 ', # whitespace
)
@patch('support.views.program_enrollments.link_program_enrollments_to_lms_users')
@patch('support.views.program_enrollments.link_program_enrollments')
def test_text(self, text, mocked_link):
self.client.post(self.url, data={
'program_uuid': self.program_uuid,
@@ -508,18 +523,20 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
})
mocked_link.assert_called_once()
mocked_link.assert_called_with(
self.program_uuid,
UUID(self.program_uuid),
{
'0001': 'learner-01',
'0002': 'learner-02',
}
)
def test_junk_text(self):
@patch_render
def test_junk_text(self, mocked_render):
text = 'alsdjflajsdflakjs'
response = self.client.post(self.url, data={
self.client.post(self.url, data={
'program_uuid': self.program_uuid,
'text': text,
})
msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(program_uuid=self.program_uuid, external_student_key=text)
self._assert_props_list('errors', [msg], response)
msg = u"All linking lines must be in the format 'external_user_key,lms_username'"
render_call_dict = mocked_render.call_args[0][1]
assert render_call_dict['errors'] == [msg]

View File

@@ -1,17 +1,18 @@
"""
Support tool for changing course enrollments.
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import csv
from uuid import UUID
from django.utils.decorators import method_decorator
from django.views.generic import View
from edxmako.shortcuts import render_to_response
from lms.djangoapps.program_enrollments.api import link_program_enrollments
from lms.djangoapps.support.decorators import require_support_permission
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
TEMPLATE_PATH = 'support/link_program_enrollments.html'
@@ -44,25 +45,7 @@ class LinkProgramEnrollmentSupportView(View):
"""
program_uuid = request.POST.get('program_uuid', '').strip()
text = request.POST.get('text', '')
successes = []
errors = []
if not program_uuid or not text:
error = 'You must provide both a program uuid and a comma separated list of external_student_key, username'
errors = [error]
else:
reader = csv.DictReader(text.splitlines(), fieldnames=('external_key', 'username'))
ext_key_to_lms_username = {
(item['external_key'] or '').strip(): (item['username'] or '').strip()
for item in reader
}
try:
link_errors = link_program_enrollments_to_lms_users(program_uuid, ext_key_to_lms_username)
except ValueError as e:
errors = [str(e)]
else:
successes = [str(item) for item in ext_key_to_lms_username.items() if item not in link_errors]
errors = [message for message in link_errors.values()]
successes, errors = self._validate_and_link(program_uuid, text)
return render_to_response(
TEMPLATE_PATH,
{
@@ -72,3 +55,46 @@ class LinkProgramEnrollmentSupportView(View):
'text': text,
}
)
@staticmethod
def _validate_and_link(program_uuid_string, linkage_text):
"""
Validate arguments, and if valid, call `link_program_enrollments`.
Returns: (successes, errors)
where successes and errors are both list[str]
"""
if not (program_uuid_string and linkage_text):
error = (
"You must provide both a program uuid "
"and a series of lines with the format "
"'external_user_key,lms_username'."
)
return [], [error]
try:
program_uuid = UUID(program_uuid_string)
except ValueError:
return [], [
"Supplied program UUID '{}' is not a valid UUID.".format(program_uuid_string)
]
reader = csv.DictReader(
linkage_text.splitlines(), fieldnames=('external_key', 'username')
)
ext_key_to_username = {
(item.get('external_key') or '').strip(): (item['username'] or '').strip()
for item in reader
}
if not (all(ext_key_to_username.keys()) and all(ext_key_to_username.values())):
return [], [
"All linking lines must be in the format 'external_user_key,lms_username'"
]
link_errors = link_program_enrollments(
program_uuid, ext_key_to_username
)
successes = [
str(item)
for item in ext_key_to_username.items()
if item not in link_errors
]
errors = [message for message in link_errors.values()]
return successes, errors