Revert "Create Python API for program_enrollments: Part IV"

This reverts commit af4e7a348a.

After merging the above commit, I was seeing 'internal-error's
on Stage in responses to the Registrar API. I am temporarily
reverting it until I can figure out a fix.
This commit is contained in:
Kyle McCormick
2019-09-20 12:45:09 -04:00
committed by Simon Chen
parent e7a509faf0
commit 775d2fd807
26 changed files with 1396 additions and 1585 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,28 +120,6 @@ def verify_course_exists_and_in_program(view_func):
return wrapped_function
def get_enrollment_http_code(result_statuses, ok_statuses):
"""
Given a set of enrollment create/update statuses,
return the appropriate HTTP status code.
Arguments:
result_statuses (sequence[str]): set of enrollment operation statuses
(for example, 'enrolled', 'not-in-program', etc.)
ok_statuses: sequence[str]: set of 'OK' (non-error) statuses
"""
result_status_set = set(result_statuses)
ok_status_set = set(ok_statuses)
if not result_status_set:
return status.HTTP_204_NO_CONTENT
if result_status_set.issubset(ok_status_set):
return status.HTTP_200_OK
elif result_status_set & ok_status_set:
return status.HTTP_207_MULTI_STATUS
else:
return status.HTTP_422_UNPROCESSABLE_ENTITY
def get_due_dates(request, course_key, user):
"""
Get due date information for a user for blocks in a course.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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