Create Python API for program_enrollments: Part III

This is the third in a series of commits to create
a Python API for the LMS program_enrollments app.
It does the following:
* Creates api/ folder.
* Moves link_program_enrollments.py to api/linking.py
* Creates api/reading.py for enrollment-fetching
  functions.
* Updates rest of app to use api/reading.py when
  it was going directly through the models before.
* Other misc. cleanup (isorting, unicode_literals,
  line breaks, etc).

Still to do:
* Create api/writing.py and update app to use it instead
  of going directly through models.
* Create api/reset.py and api/expire.py, which the management
  commands call out to.

EDUCATOR-4321
This commit is contained in:
Kyle McCormick
2019-09-05 12:02:01 -04:00
committed by Kyle McCormick
parent 77aacee6ed
commit 358f989131
26 changed files with 1058 additions and 279 deletions

View File

@@ -0,0 +1,11 @@
"""
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 wildcard-imported into here so they can be imported directly from
`lms.djangoapps.program_enrollments.api`.
"""
from __future__ import absolute_import
from .linking import * # pylint: disable=wildcard-import
from .reading import * # pylint: disable=wildcard-import

View File

@@ -2,17 +2,17 @@
"""
ProgramEnrollment internal API intended for Enterprise API.
This is not part of the program_enrollments Python API.
The Enterprise API currently depends on this module being present with these
functions, as implemented in ./utils.py. This module will be refactored
away in https://openedx.atlassian.net/browse/ENT-2294
"""
from __future__ import absolute_import, unicode_literals
from lms.djangoapps.program_enrollments.rest_api.v1.utils import (
get_due_dates as get_due_dates_util,
get_course_run_url as get_course_run_url_util,
get_emails_enabled as get_emails_enabled_util,
)
from lms.djangoapps.program_enrollments.rest_api.v1.utils import get_course_run_url as get_course_run_url_util
from lms.djangoapps.program_enrollments.rest_api.v1.utils import get_due_dates as get_due_dates_util
from lms.djangoapps.program_enrollments.rest_api.v1.utils import get_emails_enabled as get_emails_enabled_util
def get_due_dates(request, course_key, user):

View File

@@ -1,32 +1,43 @@
""" Function to link program enrollments and external_student_keys to an LMS user """
"""
Python API function to link program enrollments and external_student_keys to an
LMS user.
Outside of this subpackage, import these functions
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
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from student.models import CourseEnrollmentException
from .reading import fetch_program_enrollments
logger = logging.getLogger(__name__)
User = get_user_model()
NO_PROGRAM_ENROLLMENT_TPL = (
u'No program enrollment found for program uuid={program_uuid} and external student '
NO_PROGRAM_ENROLLMENT_TEMPLATE = (
'No program enrollment found for program uuid={program_uuid} and external student '
'key={external_student_key}'
)
NO_LMS_USER_TPL = u'No user found with username {}'
COURSE_ENROLLMENT_ERR_TPL = (
u'Failed to enroll user {user} with waiting program course enrollment for course {course}'
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_TPL = (
u'Program enrollment with external_student_key={external_student_key} is already linked to '
u'{account_relation} account username={username}'
EXISTING_USER_TEMPLATE = (
'Program enrollment with external_student_key={external_student_key} is already linked to '
'{account_relation} account username={username}'
)
@transaction.atomic
def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames):
u"""
"""
Utility function to link ProgramEnrollments to LMS Users
Arguments:
@@ -61,24 +72,26 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
"""
_validate_inputs(program_uuid, external_keys_to_usernames)
errors = {}
program_enrollments = get_program_enrollments(program_uuid, external_keys_to_usernames.keys())
users = get_lms_users(external_keys_to_usernames.values())
program_enrollments = _get_program_enrollments_by_ext_key(
program_uuid, external_keys_to_usernames.keys()
)
users = _get_lms_users(external_keys_to_usernames.values())
for item in external_keys_to_usernames.items():
external_student_key, username = item
user = users.get(username)
error_message = None
if not user:
error_message = NO_LMS_USER_TPL.format(username)
error_message = NO_LMS_USER_TEMPLATE.format(username)
program_enrollment = program_enrollments.get(external_student_key)
if not program_enrollment:
error_message = NO_PROGRAM_ENROLLMENT_TPL.format(
error_message = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(
program_uuid=program_uuid,
external_student_key=external_student_key
)
elif program_enrollment.user:
error_message = get_existing_user_message(program_enrollment, user)
error_message = user_already_linked_message(program_enrollment, user)
if error_message:
logger.warning(error_message)
@@ -89,7 +102,7 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
with transaction.atomic():
link_program_enrollment_to_lms_user(program_enrollment, user)
except (CourseEnrollmentException, IntegrityError) as e:
logger.exception(u"Rolling back all operations for {}:{}".format(
logger.exception("Rolling back all operations for {}:{}".format(
external_student_key,
username,
))
@@ -101,39 +114,6 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
return errors
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(program_uuid, external_student_keys):
"""
Does a bulk read of ProgramEnrollments for a given program and list of external student keys
and returns a dict keyed by external student key
"""
program_enrollments = ProgramEnrollment.bulk_read_by_student_key(
program_uuid,
external_student_keys
).prefetch_related(
'program_course_enrollments'
).select_related('user')
return {
program_enrollment.external_user_key: program_enrollment
for program_enrollment in program_enrollments
}
def get_lms_users(lms_usernames):
"""
Does a bulk read of Users by username and returns a dict keyed by username
"""
return {
user.username: user
for user in User.objects.filter(username__in=lms_usernames)
}
def link_program_enrollment_to_lms_user(program_enrollment, user):
"""
Attempts to link the given program enrollment to the given user
@@ -151,13 +131,59 @@ def link_program_enrollment_to_lms_user(program_enrollment, user):
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
"""
existing_username = program_enrollment.user.username
external_student_key = program_enrollment.external_user_key
return EXISTING_USER_TEMPLATE.format(
external_student_key=external_student_key,
account_relation='target' if program_enrollment.user.id == user.id else 'a different',
username=existing_username,
)
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
and returns a dict keyed by external student key
"""
program_enrollments = fetch_program_enrollments(
program_uuid=program_uuid,
external_user_keys=external_student_keys,
).prefetch_related(
'program_course_enrollments'
).select_related('user')
return {
program_enrollment.external_user_key: program_enrollment
for program_enrollment in program_enrollments
}
def _get_lms_users(lms_usernames):
"""
Does a bulk read of Users by username and returns a dict keyed by username
"""
return {
user.username: user
for user in User.objects.filter(username__in=lms_usernames)
}
def _link_program_enrollment(program_enrollment, user):
"""
Links program enrollment to user.
Raises IntegrityError if ProgramEnrollment is invalid
"""
logger.info(u'Linking external student key {} and user {}'.format(
logger.info('Linking external student key {} and user {}'.format(
program_enrollment.external_user_key,
user.username
))
@@ -177,22 +203,9 @@ def _link_course_enrollments(program_enrollment, user):
for program_course_enrollment in program_enrollment.program_course_enrollments.all():
program_course_enrollment.enroll(user)
except CourseEnrollmentException as e:
error_message = COURSE_ENROLLMENT_ERR_TPL.format(
error_message = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
user=user.username,
course=program_course_enrollment.course_key
)
logger.exception(error_message)
raise type(e)(error_message)
def get_existing_user_message(program_enrollment, user):
"""
Creates an error message that the specified program enrollment is already linked to an lms user
"""
existing_username = program_enrollment.user.username
external_student_key = program_enrollment.external_user_key
return EXISTING_USER_TPL.format(
external_student_key=external_student_key,
account_relation='target' if program_enrollment.user.id == user.id else 'a different',
username=existing_username,
)

View File

@@ -0,0 +1,321 @@
"""
Python API functions related to reading program enrollments.
Outside of this subpackage, import these functions
from `lms.djangoapps.program_enrollments.api`.
"""
from __future__ import absolute_import, unicode_literals
from ..models import ProgramCourseEnrollment, ProgramEnrollment
_STUDENT_ARG_ERROR_MESSAGE = (
"user and external_user_key are both None; at least one must be provided."
)
_REALIZED_FILTER_ERROR_TEMPLATE = (
"{} and {} are mutually exclusive; at most one of them may be passed in as True."
)
def get_program_enrollment(
program_uuid,
user=None,
external_user_key=None,
curriculum_uuid=None,
):
"""
Get a single program enrollment.
Required arguments:
* program_uuid (UUID|str)
* At least one of:
* user (User)
* external_user_key (str)
Optional arguments:
* curriculum_uuid (UUID|str) [optional]
Returns: ProgramEnrollment
Raises: ProgramEnrollment.DoesNotExist, ProgramEnrollment.MultipleObjectsReturned
"""
if not (user or external_user_key):
raise ValueError(_STUDENT_ARG_ERROR_MESSAGE)
filters = {
"user": user,
"external_user_key": external_user_key,
"curriculum_uuid": curriculum_uuid,
}
return ProgramEnrollment.objects.get(
program_uuid=program_uuid, **_remove_none_values(filters)
)
def get_program_course_enrollment(
program_uuid,
course_key,
user=None,
external_user_key=None,
curriculum_uuid=None,
):
"""
Get a single program-course enrollment.
Required arguments:
* program_uuid (UUID|str)
* course_key (CourseKey|str)
* At least one of:
* user (User)
* external_user_key (str)
Optional arguments:
* curriculum_uuid (UUID|str) [optional]
Returns: ProgramCourseEnrollment
Raises:
* ProgramCourseEnrollment.DoesNotExist
* ProgramCourseEnrollment.MultipleObjectsReturned
"""
if not (user or external_user_key):
raise ValueError(_STUDENT_ARG_ERROR_MESSAGE)
filters = {
"program_enrollment__user": user,
"program_enrollment__external_user_key": external_user_key,
"program_enrollment__curriculum_uuid": curriculum_uuid,
}
return ProgramCourseEnrollment.objects.get(
program_enrollment__program_uuid=program_uuid,
course_key=course_key,
**_remove_none_values(filters)
)
def fetch_program_enrollments(
program_uuid,
curriculum_uuids=None,
users=None,
external_user_keys=None,
program_enrollment_statuses=None,
realized_only=False,
waiting_only=False,
):
"""
Fetch program enrollments for a specific program.
Required argument:
* program_uuid (UUID|str)
Optional arguments:
* curriculum_uuids (iterable[UUID|str])
* users (iterable[User])
* external_user_keys (iterable[str])
* program_enrollment_statuses (iterable[str])
* realized_only (bool)
* waiting_only (bool)
Optional arguments are used as filtersets if they are not None.
At most one of (realized_only, waiting_only) may be provided.
Returns: queryset[ProgramEnrollment]
"""
if realized_only and waiting_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("realized_only", "waiting_only")
)
filters = {
"curriculum_uuid__in": curriculum_uuids,
"user__in": users,
"external_user_key__in": external_user_keys,
"status__in": program_enrollment_statuses,
}
if realized_only:
filters["user__isnull"] = False
if waiting_only:
filters["user__isnull"] = True
return ProgramEnrollment.objects.filter(
program_uuid=program_uuid, **_remove_none_values(filters)
)
def fetch_program_course_enrollments(
program_uuid,
course_key,
curriculum_uuids=None,
users=None,
external_user_keys=None,
program_enrollment_statuses=None,
active_only=False,
inactive_only=False,
realized_only=False,
waiting_only=False,
):
"""
Fetch program-course enrollments for a specific program and course run.
Required argument:
* program_uuid (UUID|str)
* course_key (CourseKey|str)
Optional arguments:
* curriculum_uuids (iterable[UUID|str])
* users (iterable[User])
* external_user_keys (iterable[str])
* program_enrollment_statuses (iterable[str])
* active_only (bool)
* inactive_only (bool)
* realized_only (bool)
* waiting_only (bool)
Optional arguments are used as filtersets if they are not None.
At most one of (realized_only, waiting_only) may be provided.
At most one of (active_only, inactive_only) may be provided.
Returns: queryset[ProgramCourseEnrollment]
"""
if active_only and inactive_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("active_only", "inactive_only")
)
if realized_only and waiting_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("realized_only", "waiting_only")
)
filters = {
"program_enrollment__curriculum_uuid__in": curriculum_uuids,
"program_enrollment__user__in": users,
"program_enrollment__external_user_key__in": external_user_keys,
"program_enrollment__status__in": program_enrollment_statuses,
}
if active_only:
filters["status"] = "active"
if inactive_only:
filters["status"] = "inactive"
if realized_only:
filters["program_enrollment__user__isnull"] = False
if waiting_only:
filters["program_enrollment__user__isnull"] = True
return ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=program_uuid,
course_key=course_key,
**_remove_none_values(filters)
)
def fetch_program_enrollments_by_student(
user=None,
external_user_key=None,
program_uuids=None,
curriculum_uuids=None,
program_enrollment_statuses=None,
realized_only=False,
waiting_only=False,
):
"""
Fetch program enrollments for a specific student.
Required arguments (at least one must be provided):
* user (User)
* external_user_key (str)
Optional arguments:
* provided_uuids (iterable[UUID|str])
* curriculum_uuids (iterable[UUID|str])
* program_enrollment_statuses (iterable[str])
* realized_only (bool)
* waiting_only (bool)
Optional arguments are used as filtersets if they are not None.
At most one of (realized_only, waiting_only) may be provided.
Returns: queryset[ProgramEnrollment]
"""
if not (user or external_user_key):
raise ValueError(_STUDENT_ARG_ERROR_MESSAGE)
if realized_only and waiting_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("realized_only", "waiting_only")
)
filters = {
"user": user,
"external_user_key": external_user_key,
"program_uuid__in": program_uuids,
"curriculum_uuid__in": curriculum_uuids,
"status__in": program_enrollment_statuses,
}
if realized_only:
filters["user__isnull"] = False
if waiting_only:
filters["user__isnull"] = True
return ProgramEnrollment.objects.filter(**_remove_none_values(filters))
def fetch_program_course_enrollments_by_student(
user=None,
external_user_key=None,
program_uuids=None,
curriculum_uuids=None,
course_keys=None,
program_enrollment_statuses=None,
active_only=False,
inactive_only=False,
realized_only=False,
waiting_only=False,
):
"""
Fetch program-course enrollments for a specific student.
Required arguments (at least one must be provided):
* user (User)
* external_user_key (str)
Optional arguments:
* provided_uuids (iterable[UUID|str])
* curriculum_uuids (iterable[UUID|str])
* course_keys (iterable[CourseKey|str])
* program_enrollment_statuses (iterable[str])
* realized_only (bool)
* waiting_only (bool)
Optional arguments are used as filtersets if they are not None.
At most one of (realized_only, waiting_only) may be provided.
At most one of (active_only, inactive_only) may be provided.
Returns: queryset[ProgramCourseEnrollment]
"""
if not (user or external_user_key):
raise ValueError(_STUDENT_ARG_ERROR_MESSAGE)
if active_only and inactive_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("active_only", "inactive_only")
)
if realized_only and waiting_only:
raise ValueError(
_REALIZED_FILTER_ERROR_TEMPLATE.format("realized_only", "waiting_only")
)
filters = {
"program_enrollment__user": user,
"program_enrollment__external_user_key": external_user_key,
"program_enrollment__program_uuid__in": program_uuids,
"program_enrollment__curriculum_uuid__in": curriculum_uuids,
"course_key__in": course_keys,
"program_enrollment__status__in": program_enrollment_statuses,
}
if active_only:
filters["status"] = "active"
if inactive_only:
filters["status"] = "inactive"
if realized_only:
filters["program_enrollment__user__isnull"] = False
if waiting_only:
filters["program_enrollment__user__isnull"] = True
return ProgramCourseEnrollment.objects.filter(**_remove_none_values(filters))
def _remove_none_values(dictionary):
"""
Return a dictionary where key-value pairs with `None` as the value
are removed.
"""
return {
key: value for key, value in dictionary.items() if value is not None
}

View File

@@ -1,31 +1,32 @@
"""
Tests for link_program_enrollments.
Tests for account linking Python API.
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
from uuid import uuid4
from testfixtures import LogCapture
from django.test import TestCase
from edx_django_utils.cache import RequestCache
from lms.djangoapps.program_enrollments.link_program_enrollments import (
link_program_enrollments_to_lms_users,
NO_PROGRAM_ENROLLMENT_TPL,
NO_LMS_USER_TPL,
COURSE_ENROLLMENT_ERR_TPL,
get_existing_user_message,
)
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from opaque_keys.edx.keys import CourseKey
from testfixtures import LogCapture
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.tests.factories import UserFactory
LOG_PATH = 'lms.djangoapps.program_enrollments.link_program_enrollments'
from ..linking import (
COURSE_ENROLLMENT_ERR_TEMPLATE,
NO_LMS_USER_TEMPLATE,
NO_PROGRAM_ENROLLMENT_TEMPLATE,
link_program_enrollments_to_lms_users,
user_already_linked_message
)
LOG_PATH = 'lms.djangoapps.program_enrollments.api.linking'
class TestLinkProgramEnrollmentsMixin(object):
""" Utility methods and test data for testing link_program_enrollments """
""" Utility methods and test data for testing linking """
@classmethod
def setUpTestData(cls): # pylint: disable=missing-docstring
@@ -57,7 +58,8 @@ class TestLinkProgramEnrollmentsMixin(object):
def _create_waiting_course_enrollment(self, program_enrollment, course_key, status='active'):
"""
Create a waiting program course enrollment for the given program enrollment, course key, and optionally status
Create a waiting program course enrollment for the given program enrollment,
course key, and optionally status.
"""
return ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment,
@@ -84,19 +86,25 @@ class TestLinkProgramEnrollmentsMixin(object):
def _assert_program_enrollment(self, user, program_uuid, external_user_key, refresh=True):
"""
Assert that the given user is enrolled in the given program with the given external user key
Assert that the given user is enrolled in the given program with the
given external user key.
"""
if refresh:
user.refresh_from_db()
enrollment = user.programenrollment_set.get(program_uuid=program_uuid, external_user_key=external_user_key)
enrollment = user.programenrollment_set.get(
program_uuid=program_uuid, external_user_key=external_user_key
)
self.assertIsNotNone(enrollment)
def _assert_user_enrolled_in_program_courses(self, user, program_uuid, *course_keys):
"""
Assert that the given user is has active enrollments in the given courses through the given program
Assert that the given user is has active enrollments in the given courses
through the given program.
"""
user.refresh_from_db()
program_enrollment = user.programenrollment_set.get(user=user, program_uuid=program_uuid)
program_enrollment = user.programenrollment_set.get(
user=user, program_uuid=program_uuid
)
all_course_enrollments = program_enrollment.program_course_enrollments
program_course_enrollments = all_course_enrollments.select_related(
'course_enrollment__course'
@@ -107,7 +115,9 @@ class TestLinkProgramEnrollmentsMixin(object):
program_course_enrollment.course_enrollment
for program_course_enrollment in program_course_enrollments
]
self.assertTrue(all(course_enrollment.is_active for course_enrollment in course_enrollments))
self.assertTrue(
all(course_enrollment.is_active for course_enrollment in course_enrollments)
)
self.assertCountEqual(
course_keys,
[course_enrollment.course.id for course_enrollment in course_enrollments]
@@ -124,7 +134,7 @@ class TestLinkProgramEnrollmentsMixin(object):
class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
""" Tests for link_program_enrollments behavior """
""" Tests for linking behavior """
def test_link_only_specified_program(self):
"""
@@ -142,14 +152,17 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
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(self.user_1, self.program, self.fruit_course, self.animal_course)
self._assert_user_enrolled_in_program_courses(
self.user_1, self.program, self.fruit_course, self.animal_course
)
self._assert_no_user(another_program_enrollment)
def test_inactive_waiting_course_enrollment(self):
"""
Test that when a waiting program enrollment has waiting program course enrollments with a status of 'inactive'
the course enrollment created after calling link_program_enrollments will be inactive
Test that when a waiting program enrollment has waiting program course enrollments with a
status of 'inactive' the course enrollment created after calling link_program_enrollments
will be inactive.
"""
program_enrollment = self._create_waiting_enrollment(self.program, '0001')
active_enrollment = self._create_waiting_course_enrollment(
@@ -178,7 +191,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase):
""" Tests for link_program_enrollments error behavior """
""" Tests for linking error behavior """
def test_program_enrollment_not_found__nonexistant(self):
self._create_waiting_enrollment(self.program, '0001')
@@ -203,7 +216,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
'0002': self.user_2.username,
}
)
expected_error_msg = NO_PROGRAM_ENROLLMENT_TPL.format(
expected_error_msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(
program_uuid=self.program,
external_student_key='0002'
)
@@ -225,7 +238,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
'0002': 'nonexistant-user',
}
)
expected_error_msg = NO_LMS_USER_TPL.format('nonexistant-user')
expected_error_msg = NO_LMS_USER_TEMPLATE.format('nonexistant-user')
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg})
@@ -250,7 +263,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
'0002': self.user_2.username
}
)
expected_error_msg = get_existing_user_message(program_enrollment, self.user_2)
expected_error_msg = user_already_linked_message(program_enrollment, self.user_2)
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
@@ -277,7 +290,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
'0003': self.user_2.username,
}
)
expected_error_msg = get_existing_user_message(enrollment, self.user_2)
expected_error_msg = user_already_linked_message(enrollment, self.user_2)
logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
self.assertDictEqual(errors, {('0003', self.user_2.username): expected_error_msg})
@@ -289,14 +302,20 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
nonexistant_course = CourseKey.from_string('course-v1:edX+Zilch+Bupkis')
program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001')
course_enrollment_1 = self._create_waiting_course_enrollment(program_enrollment_1, nonexistant_course)
course_enrollment_2 = self._create_waiting_course_enrollment(program_enrollment_1, self.animal_course)
course_enrollment_1 = self._create_waiting_course_enrollment(
program_enrollment_1, nonexistant_course
)
course_enrollment_2 = self._create_waiting_course_enrollment(
program_enrollment_1, self.animal_course
)
program_enrollment_2 = self._create_waiting_enrollment(self.program, '0002')
self._create_waiting_course_enrollment(program_enrollment_2, self.fruit_course)
self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course)
msg = COURSE_ENROLLMENT_ERR_TPL.format(user=self.user_1.username, course=nonexistant_course)
msg = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
user=self.user_1.username, course=nonexistant_course
)
with LogCapture() as logger:
errors = link_program_enrollments_to_lms_users(
self.program,
@@ -307,7 +326,9 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
)
logger.check_present((LOG_PATH, 'ERROR', msg))
self.assertDictEqual(errors, {('0001', self.user_1.username): 'NonExistentCourseError: ' + msg})
self.assertDictEqual(
errors, {('0001', self.user_1.username): 'NonExistentCourseError: ' + msg}
)
self._assert_no_program_enrollment(self.user_1, self.program)
self._assert_no_user(program_enrollment_1)
course_enrollment_1.refresh_from_db()
@@ -315,7 +336,9 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
course_enrollment_2.refresh_from_db()
self.assertIsNone(course_enrollment_2.course_enrollment)
self._assert_user_enrolled_in_program_courses(self.user_2, self.program, self.animal_course, self.fruit_course)
self._assert_user_enrolled_in_program_courses(
self.user_2, self.program, self.animal_course, self.fruit_course
)
def test_integrity_error(self):
existing_program_enrollment = self._create_waiting_enrollment(self.program, 'learner-0')

View File

@@ -0,0 +1,427 @@
"""
Tests for account linking Python API.
"""
from __future__ import absolute_import, unicode_literals
from uuid import UUID
import ddt
from django.contrib.auth import get_user_model
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
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.models import ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.tests.factories import CourseEnrollmentFactory, UserFactory
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
)
User = get_user_model()
@ddt.ddt
class ProgramEnrollmentReadingTests(TestCase):
"""
Tests for program enrollment reading functions.
"""
program_uuid_x = UUID('dddddddd-5f48-493d-9410-84e1d36c657f')
program_uuid_y = UUID('eeeeeeee-f803-43f6-bbf3-5ae15d393649')
program_uuid_z = UUID('ffffffff-89eb-43df-a6b9-c144e7204fd7') # No enrollments
curriculum_uuid_a = UUID('aaaaaaaa-bd26-43d0-94b8-b0063858210b')
curriculum_uuid_b = UUID('bbbbbbbb-145f-43db-ad05-f9ad65eec285')
curriculum_uuid_c = UUID('cccccccc-4577-4559-85f0-4a83e8160a4d')
course_key_p = CourseKey.from_string('course-v1:TestX+ProEnroll+P')
course_key_q = CourseKey.from_string('course-v1:TestX+ProEnroll+Q')
course_key_r = CourseKey.from_string('course-v1:TestX+ProEnroll+R')
username_0 = 'user-0'
username_1 = 'user-1'
username_2 = 'user-2'
username_3 = 'user-3'
username_4 = 'user-4'
ext_3 = 'student-3'
ext_4 = 'student-4'
ext_5 = 'student-5'
ext_6 = 'student-6'
@classmethod
def setUpTestData(cls):
super(ProgramEnrollmentReadingTests, cls).setUpTestData()
cls.user_0 = UserFactory(username=cls.username_0) # No enrollments
cls.user_1 = UserFactory(username=cls.username_1)
cls.user_2 = UserFactory(username=cls.username_2)
cls.user_3 = UserFactory(username=cls.username_3)
cls.user_4 = UserFactory(username=cls.username_4)
CourseOverviewFactory(id=cls.course_key_p)
CourseOverviewFactory(id=cls.course_key_q)
CourseOverviewFactory(id=cls.course_key_r)
enrollment_test_data = [ # ID
(cls.user_1, None, cls.program_uuid_x, cls.curriculum_uuid_a, PEStatuses.ENROLLED), # 1
(cls.user_2, None, cls.program_uuid_x, cls.curriculum_uuid_a, PEStatuses.PENDING), # 2
(cls.user_3, cls.ext_3, cls.program_uuid_x, cls.curriculum_uuid_b, PEStatuses.ENROLLED), # 3
(cls.user_4, cls.ext_4, cls.program_uuid_x, cls.curriculum_uuid_b, PEStatuses.PENDING), # 4
(None, cls.ext_5, cls.program_uuid_x, cls.curriculum_uuid_b, PEStatuses.SUSPENDED), # 5
(None, cls.ext_6, cls.program_uuid_y, cls.curriculum_uuid_c, PEStatuses.CANCELED), # 6
(cls.user_3, cls.ext_3, cls.program_uuid_y, cls.curriculum_uuid_c, PEStatuses.CANCELED), # 7
(None, cls.ext_4, cls.program_uuid_y, cls.curriculum_uuid_c, PEStatuses.ENROLLED), # 8
(cls.user_1, None, cls.program_uuid_x, cls.curriculum_uuid_b, PEStatuses.SUSPENDED), # 9
]
for user, external_user_key, program_uuid, curriculum_uuid, status in enrollment_test_data:
ProgramEnrollmentFactory(
user=user,
external_user_key=external_user_key,
program_uuid=program_uuid,
curriculum_uuid=curriculum_uuid,
status=status,
)
course_enrollment_test_data = [ # ID
(1, cls.course_key_p, PCEStatuses.ACTIVE), # 1
(1, cls.course_key_q, PCEStatuses.ACTIVE), # 2
(9, cls.course_key_r, PCEStatuses.ACTIVE), # 3
(2, cls.course_key_p, PCEStatuses.INACTIVE), # 4
(3, cls.course_key_p, PCEStatuses.ACTIVE), # 5
(5, cls.course_key_p, PCEStatuses.INACTIVE), # 6
(8, cls.course_key_p, PCEStatuses.ACTIVE), # 7
(8, cls.course_key_q, PCEStatuses.INACTIVE), # 8
(2, cls.course_key_r, PCEStatuses.INACTIVE), # 9
(6, cls.course_key_r, PCEStatuses.INACTIVE), # 10
(8, cls.course_key_r, PCEStatuses.ACTIVE), # 11
(7, cls.course_key_q, PCEStatuses.ACTIVE), # 12
]
for program_enrollment_id, course_key, status in course_enrollment_test_data:
program_enrollment = ProgramEnrollment.objects.get(id=program_enrollment_id)
course_enrollment = (
CourseEnrollmentFactory(
course_id=course_key,
user=program_enrollment.user,
mode=CourseMode.MASTERS,
)
if program_enrollment.user
else None
)
ProgramCourseEnrollmentFactory(
program_enrollment=program_enrollment,
course_enrollment=course_enrollment,
course_key=course_key,
status=status,
)
@ddt.data(
# Realized enrollment, specifying only user.
(program_uuid_x, curriculum_uuid_a, username_1, None, 1),
# Realized enrollment, specifiying both user and external key.
(program_uuid_x, curriculum_uuid_b, username_3, ext_3, 3),
# Realized enrollment, specifiying only external key.
(program_uuid_x, curriculum_uuid_b, None, ext_4, 4),
# Waiting enrollment, specifying external key
(program_uuid_x, curriculum_uuid_b, None, ext_5, 5),
# Specifying no curriculum (because ext_6 only has Program Y
# enrollments in one curriculum, so it's not ambiguous).
(program_uuid_y, None, None, ext_6, 6),
)
@ddt.unpack
def test_get_program_enrollment(
self,
program_uuid,
curriculum_uuid,
username,
external_user_key,
expected_enrollment_id,
):
user = User.objects.get(username=username) if username else None
actual_enrollment = get_program_enrollment(
program_uuid=program_uuid,
curriculum_uuid=curriculum_uuid,
user=user,
external_user_key=external_user_key,
)
assert actual_enrollment.id == expected_enrollment_id
@ddt.data(
# Realized enrollment, specifying only user.
(program_uuid_x, None, course_key_p, username_1, None, 1),
# Realized enrollment, specifiying both user and external key.
(program_uuid_x, None, course_key_p, username_3, ext_3, 5),
# Realized enrollment, specifiying only external key.
(program_uuid_y, None, course_key_p, None, ext_4, 7),
# Waiting enrollment, specifying external key
(program_uuid_x, None, course_key_p, None, ext_5, 6),
# We can specify curriculum, but it shouldn't affect anything,
# because each user-course pairing can only have one
# program-course enrollment.
(program_uuid_y, curriculum_uuid_c, course_key_r, None, ext_6, 10),
)
@ddt.unpack
def test_get_program_course_enrollment(
self,
program_uuid,
curriculum_uuid,
course_key,
username,
external_user_key,
expected_enrollment_id,
):
user = User.objects.get(username=username) if username else None
actual_enrollment = get_program_course_enrollment(
program_uuid=program_uuid,
curriculum_uuid=curriculum_uuid,
course_key=course_key,
user=user,
external_user_key=external_user_key,
)
assert actual_enrollment.id == expected_enrollment_id
@ddt.data(
# Program with no enrollments
(
{'program_uuid': program_uuid_z},
set(),
),
# Curriculum & status filters
(
{
'program_uuid': program_uuid_x,
'curriculum_uuids': {curriculum_uuid_a, curriculum_uuid_c},
'program_enrollment_statuses': {PEStatuses.PENDING, PEStatuses.CANCELED},
},
{2},
),
# User & external key filters
(
{
'program_uuid': program_uuid_x,
'usernames': {username_1, username_2, username_3, username_4},
'external_user_keys': {ext_3, ext_4, ext_5}
},
{3, 4},
),
# Realized-only filter
(
{'program_uuid': program_uuid_x, 'realized_only': True},
{1, 2, 3, 4, 9},
),
# Waiting-only filter
(
{'program_uuid': program_uuid_x, 'waiting_only': True},
{5},
),
)
@ddt.unpack
def test_fetch_program_enrollments(self, kwargs, expected_enrollment_ids):
kwargs = self._usernames_to_users(kwargs)
actual_enrollments = fetch_program_enrollments(**kwargs)
actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments}
assert actual_enrollment_ids == expected_enrollment_ids
@ddt.data(
# Program with no enrollments
(
{'program_uuid': program_uuid_z, 'course_key': course_key_p},
set(),
),
# Curriculum, status, active-only filters
(
{
'program_uuid': program_uuid_x,
'course_key': course_key_p,
'curriculum_uuids': {curriculum_uuid_a, curriculum_uuid_c},
'program_enrollment_statuses': {PEStatuses.ENROLLED},
'active_only': True,
},
{1},
),
# User and external key filters
(
{
'program_uuid': program_uuid_x,
'course_key': course_key_p,
'usernames': {username_2, username_3},
'external_user_keys': {ext_3, ext_5}
},
{5},
),
# Realized-only filter
(
{
'program_uuid': program_uuid_x,
'course_key': course_key_p,
'realized_only': True,
},
{1, 4, 5},
),
# Waiting-only and inactive-only filters
(
{
'program_uuid': program_uuid_y,
'course_key': course_key_r,
'waiting_only': True,
'inactive_only': True,
},
{10},
),
)
@ddt.unpack
def test_fetch_program_course_enrollments(self, kwargs, expected_enrollment_ids):
kwargs = self._usernames_to_users(kwargs)
actual_enrollments = fetch_program_course_enrollments(**kwargs)
actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments}
assert actual_enrollment_ids == expected_enrollment_ids
@ddt.data(
# User with no enrollments
(
{'username': username_0},
set(),
),
# Filters
(
{
'username': username_3,
'external_user_key': ext_3,
'program_uuids': {program_uuid_x},
'curriculum_uuids': {curriculum_uuid_b, curriculum_uuid_c},
'program_enrollment_statuses': {PEStatuses.ENROLLED, PEStatuses.CANCELED},
},
{3},
),
# More filters
(
{
'username': username_3,
'external_user_key': ext_3,
'program_uuids': {program_uuid_x, program_uuid_y},
'curriculum_uuids': {curriculum_uuid_b, curriculum_uuid_c},
'program_enrollment_statuses': {PEStatuses.SUSPENDED, PEStatuses.CANCELED},
},
{7},
),
# Realized-only filter
(
{'external_user_key': ext_4, 'realized_only': True},
{4},
),
# Waiting-only filter
(
{'external_user_key': ext_4, 'waiting_only': True},
{8},
),
)
@ddt.unpack
def test_fetch_program_enrollments_by_student(self, kwargs, expected_enrollment_ids):
kwargs = self._username_to_user(kwargs)
actual_enrollments = fetch_program_enrollments_by_student(**kwargs)
actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments}
assert actual_enrollment_ids == expected_enrollment_ids
@ddt.data(
# User with no program enrollments
(
{'username': username_0},
set(),
),
# Course keys and active-only filters
(
{
'external_user_key': ext_4,
'course_keys': {course_key_p, course_key_q},
'active_only': True,
},
{7},
),
# Curriculum filter
(
{'username': username_3, 'curriculum_uuids': {curriculum_uuid_b}},
{5},
),
# Program filter
(
{'username': username_3, 'program_uuids': {program_uuid_y}},
{12},
),
# Realized-only filter
(
{'external_user_key': ext_4, 'realized_only': True},
set(),
),
# Waiting-only and inactive-only filter
(
{
'external_user_key': ext_4,
'waiting_only': True,
'inactive_only': True,
},
{8},
),
)
@ddt.unpack
def test_fetch_program_course_enrollments_by_student(self, kwargs, expected_enrollment_ids):
kwargs = self._username_to_user(kwargs)
actual_enrollments = fetch_program_course_enrollments_by_student(**kwargs)
actual_enrollment_ids = {enrollment.id for enrollment in actual_enrollments}
assert actual_enrollment_ids == expected_enrollment_ids
@staticmethod
def _username_to_user(dictionary):
"""
We can't access the user instances when building `ddt.data`,
so return a dict with the username swapped out for the user themself.
"""
result = dictionary.copy()
if 'username' in result:
result['user'] = User.objects.get(username=result['username'])
del result['username']
return result
@staticmethod
def _usernames_to_users(dictionary):
"""
We can't access the user instances when building `ddt.data`,
so return a dict with the usernames swapped out for the users themselves.
"""
result = dictionary.copy()
if 'usernames' in result:
result['users'] = set(
User.objects.filter(username__in=result['usernames'])
)
del result['usernames']
return result

View File

@@ -2,6 +2,7 @@
Constants used throughout the program_enrollments app and exposed to other
in-process apps through api.py.
"""
from __future__ import absolute_import, unicode_literals
class ProgramEnrollmentStatuses(object):

View File

@@ -1,5 +1,5 @@
""" Management command to cleanup old waiting enrollments """
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import logging
@@ -32,5 +32,5 @@ class Command(BaseCommand):
def handle(self, *args, **options):
expiration_days = options.get('expiration_days')
logger.info(u'Deleting waiting enrollments unmodified for %s days', expiration_days)
logger.info('Deleting waiting enrollments unmodified for %s days', expiration_days)
tasks.expire_waiting_enrollments(expiration_days)

View File

@@ -1,51 +1,61 @@
""" Management command to link program enrollments and external student_keys to an LMS user """
from __future__ import absolute_import, unicode_literals
import logging
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from lms.djangoapps.program_enrollments.link_program_enrollments import link_program_enrollments_to_lms_users
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
logger = logging.getLogger(__name__)
User = get_user_model()
INCORRECT_PARAMETER_TPL = u'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
DUPLICATE_KEY_TPL = u'external user key {} provided multiple times'
INCORRECT_PARAMETER_TEMPLATE = (
'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
)
DUPLICATE_KEY_TEMPLATE = 'external user key {} provided multiple times'
class Command(BaseCommand):
"""
Management command to manually link ProgramEnrollments without an LMS user to an LMS user by username
Management command to manually link ProgramEnrollments without an LMS user to an LMS user by
username.
Usage:
./manage.py lms link_program_enrollments <program_uuid> <user_item>*
where a <user_item> is a string formatted as <external_user_key>:<lms_username>
Normally, program enrollments should be linked by the Django Social Auth post_save signal handler
`lms.djangoapps.program_enrollments.signals.matriculate_learner`, but in the case that a partner does not
have an IDP set up for learners to log in through, we need a way to link enrollments
Normally, program enrollments should be linked by the Django Social Auth post_save signal
handler `lms.djangoapps.program_enrollments.signals.matriculate_learner`, but in the case that
a partner does not have an IDP set up for learners to log in through, we need a way to link
enrollments.
Provided a program uuid and a list of external_user_key:lms_username, this command will look up the matching
program enrollments and users, and update the program enrollments with the matching user. If the program
enrollment has course enrollments, we will enroll the user into their waiting program courses.
Provided a program uuid and a list of external_user_key:lms_username, this command will look up
the matching program enrollments and users, and update the program enrollments with the matching
user. If the program enrollment has course enrollments, we will enroll the user into their
waiting program courses.
If an external user key is specified twice, an exception will be raised and no enrollments will be modified.
If an external user key is specified twice, an exception will be raised and no enrollments will
be modified.
For each external_user_key:lms_username, if:
- The user is not found
- 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 the input will be skipped. All other inputs will be processed and
enrollments updated.
An error message will be logged and the input will be skipped. All other inputs will be
processed and enrollments updated.
If there is an error while enrolling a user in a waiting program course enrollment, the error will be
logged, and we will roll back all transactions for that user so that their db state will be the same as
it was before this command was run. This is to allow the re-running of the same command again to correctly enroll
the user once the issue preventing the enrollment has been resolved.
If there is an error while enrolling a user in a waiting program course enrollment, the error
will be logged, and we will roll back all transactions for that user so that their db state will
be the same as it was before this command was run. This is to allow the re-running of the same
command again to correctly enroll the user once the issue preventing the enrollment has been
resolved.
No other users will be affected, they will be processed normally.
"""
help = u'Manually links ProgramEnrollment records to LMS users'
help = 'Manually links ProgramEnrollment records to LMS users'
def add_arguments(self, parser):
parser.add_argument(
@@ -77,13 +87,13 @@ class Command(BaseCommand):
for user_item in user_items:
split_args = user_item.split(':')
if len(split_args) != 2:
message = (INCORRECT_PARAMETER_TPL).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_TPL.format(external_user_key))
raise CommandError(DUPLICATE_KEY_TEMPLATE.format(external_user_key))
result[external_user_key] = lms_username
return result

View File

@@ -4,7 +4,7 @@ a side effect of enrolling students.
Intented for use in integration sandbox environments
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import logging
from textwrap import dedent
@@ -52,7 +52,7 @@ class Command(BaseCommand):
).delete()
log.info(
u'The following records will be deleted:\n%s\n%s\n',
'The following records will be deleted:\n%s\n%s\n',
deleted_course_enrollment_models,
deleted_program_enrollment_models,
)
@@ -62,4 +62,4 @@ class Command(BaseCommand):
if confirmation != 'confirm':
raise CommandError('User confirmation required. No records have been modified')
log.info(u'Deleting %s records...', q1_count + q2_count)
log.info('Deleting %s records...', q1_count + q2_count)

View File

@@ -3,27 +3,53 @@ Tests for the link_program_enrollments management command.
"""
from __future__ import absolute_import
import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from lms.djangoapps.program_enrollments.tests.test_link_program_enrollments import TestLinkProgramEnrollmentsMixin
from lms.djangoapps.program_enrollments.management.commands.link_program_enrollments import (
Command,
INCORRECT_PARAMETER_TPL,
DUPLICATE_KEY_TPL,
)
from ..link_program_enrollments import DUPLICATE_KEY_TEMPLATE, INCORRECT_PARAMETER_TEMPLATE, Command
COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_program_enrollments'
_COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_program_enrollments'
class TestLinkProgramEnrollmentManagementCommand(TestLinkProgramEnrollmentsMixin, TestCase):
""" Tests for exception behavior in the link_program_enrollments command """
class TestLinkProgramEnrollmentManagementCommand(TestCase):
"""
Test that the command calls link_program_enrollments_to_lms_users
correctly and handles exceptional input correctly.
"""
def test_incorrectly_formatted_input(self):
with self.assertRaisesRegex(CommandError, INCORRECT_PARAMETER_TPL.format('whoops')):
call_command(Command(), self.program, 'learner-01:user-01', 'whoops', 'learner-03:user-03')
program_uuid = 'a32c5da8-fb89-4f1e-97a7-b13de9e6dfa2'
def test_repeated_user_key(self):
with self.assertRaisesRegex(CommandError, DUPLICATE_KEY_TPL.format('learner-01')):
call_command(Command(), self.program, 'learner-01:user-01', 'learner-01:user-02')
_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):
call_command(
Command(), self.program_uuid, 'learner-01:user-01', 'learner-02:user-02'
)
mock_link.assert_called_once_with(
self.program_uuid,
{
'learner-01': 'user-01',
'learner-02': 'user-02',
},
)
def test_incorrectly_formatted_input_exception(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,
DUPLICATE_KEY_TEMPLATE.format('learner-01'),
):
call_command(
Command(), self.program_uuid, 'learner-01:user-01', 'learner-01:user-02'
)

View File

@@ -63,18 +63,6 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
if not (self.user or self.external_user_key):
raise ValidationError(_('One of user or external_user_key must not be null.'))
@classmethod
def bulk_read_by_student_key(cls, program_uuid, student_keys):
"""
args:
program_uuid - The UUID of the program to read enrollment data of.
student_keys - list of student keys
"""
return cls.objects.filter(
program_uuid=program_uuid,
external_user_key__in=student_keys,
)
@classmethod
def retire_user(cls, user_id):
"""
@@ -204,7 +192,7 @@ class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missin
CourseOverview.get_from_id(self.course_key)
except CourseOverview.DoesNotExist:
logger.warning(
u"User %s failed to enroll in non-existent course %s", user.id,
"User %s failed to enroll in non-existent course %s", user.id,
text_type(self.course_key),
)
raise NonExistentCourseError

View File

@@ -1,6 +1,7 @@
"""
Constants used throughout the program_enrollments V1 API.
"""
from __future__ import absolute_import, unicode_literals
from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses

View File

@@ -1,7 +1,7 @@
"""
API Serializers
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
from rest_framework import serializers
from six import text_type
@@ -216,4 +216,4 @@ class ProgramCourseGradeError(BaseProgramCourseGrade):
super(ProgramCourseGradeError, self).__init__(
program_course_enrollment
)
self.error = text_type(exception) if exception else u"Unknown error"
self.error = text_type(exception) if exception else "Unknown error"

View File

@@ -14,10 +14,10 @@ from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from freezegun import freeze_time
from opaque_keys.edx.keys import CourseKey
from organizations.tests.factories import OrganizationFactory as LMSOrganizationFactory
from pytz import UTC
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
@@ -1367,7 +1367,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
]
cls.course_staff = InstructorFactory.create(password=cls.password, course_key=cls.course_id)
cls.date = datetime(2013, 1, 22, tzinfo=UTC)
cls.date = timezone.make_aware(datetime(2013, 1, 22))
CourseEnrollmentFactory(
course_id=cls.course_id,
user=cls.course_staff,
@@ -1493,8 +1493,8 @@ class ProgramCourseEnrollmentOverviewGetTests(
# only freeze time when defining these values and not on the whole test case
# as test_multiple_enrollments_all_enrolled relies on actual differences in modified datetimes
with freeze_time('2019-01-01'):
cls.yesterday = datetime.utcnow() - timedelta(1)
cls.tomorrow = datetime.utcnow() + timedelta(1)
cls.yesterday = timezone.now() - timedelta(1)
cls.tomorrow = timezone.now() + timedelta(1)
cls.relative_certificate_download_url = '/download-the-certificates'
cls.absolute_certificate_download_url = 'http://www.certificates.com/'
@@ -1825,7 +1825,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
# course run has not ended and user has earned a passing certificate more than 30 days ago
certificate = self.create_generated_certificate()
certificate.created_date = datetime.utcnow() - timedelta(30)
certificate.created_date = timezone.now() - timedelta(30)
certificate.save()
mock_has_ended.return_value = False
@@ -1859,7 +1859,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
# course run has not ended and user has earned a passing certificate fewer than 30 days ago
certificate = self.create_generated_certificate()
certificate.created_date = datetime.utcnow() - timedelta(5)
certificate.created_date = timezone.now() - timedelta(5)
certificate.save()
response = self.client.get(self.get_url(self.program_uuid))

View File

@@ -26,6 +26,12 @@ 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
)
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,
@@ -45,7 +51,7 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAP
from student.helpers import get_resume_urls_for_enrollments
from student.models import CourseEnrollment
from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole
from util.query import use_read_replica_if_available
from util.query import read_replica_or_default
from .constants import (
ENABLE_ENROLLMENT_RESET_FLAG,
@@ -272,9 +278,9 @@ class ProgramEnrollmentsView(
@verify_program_exists
def get(self, request, program_uuid=None):
""" Defines the GET list endpoint for ProgramEnrollment objects. """
enrollments = use_read_replica_if_available(
ProgramEnrollment.objects.filter(program_uuid=program_uuid)
)
enrollments = fetch_program_enrollments(
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)
@@ -423,10 +429,10 @@ class ProgramEnrollmentsView(
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]
return {
e.external_user_key: e
for e in ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_keys)
}
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):
"""
@@ -540,14 +546,11 @@ class ProgramCourseEnrollmentsView(
"""
Get a list of students enrolled in a course within a program.
"""
enrollments = use_read_replica_if_available(
ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=program_uuid,
course_key=self.course_key
).select_related(
'program_enrollment'
)
)
enrollments = fetch_program_course_enrollments(
program_uuid, course_id
).select_related(
'program_enrollment'
).using(read_replica_or_default())
paginated_enrollments = self.paginate_queryset(enrollments)
serializer = ProgramCourseEnrollmentSerializer(paginated_enrollments, many=True)
return self.get_paginated_response(serializer.data)
@@ -667,11 +670,10 @@ class ProgramCourseEnrollmentsView(
to that user's existing program enrollment in <self.program>
"""
external_user_keys = [e["student_key"] for e in enrollments]
existing_enrollments = ProgramEnrollment.objects.filter(
external_user_key__in=external_user_keys,
existing_enrollments = fetch_program_enrollments(
program_uuid=program_uuid,
)
existing_enrollments = existing_enrollments.prefetch_related('program_course_enrollments')
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):
@@ -813,16 +815,14 @@ class ProgramCourseGradesView(
Returns: list[BaseProgramCourseGrade]
"""
enrollments_qs = use_read_replica_if_available(
ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=program_uuid,
program_enrollment__user__isnull=False,
course_key=course_key,
).select_related(
'program_enrollment',
'program_enrollment__user',
)
)
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 []
@@ -961,13 +961,11 @@ class UserProgramReadOnlyAccessView(DeveloperErrorViewMixin, PaginatedAPIView):
elif self.is_course_staff(request_user):
programs = self.get_programs_user_is_course_staff_for(request_user, requested_program_type)
else:
program_enrollments = ProgramEnrollment.objects.filter(
program_enrollments = fetch_program_enrollments_by_student(
user=request.user,
status__in=('enrolled', 'pending')
program_enrollment_statuses=ProgramEnrollmentStatuses.__ACTIVE__,
)
uuids = [enrollment.program_uuid for enrollment in program_enrollments]
programs = get_programs(uuids=uuids) or []
programs_in_which_user_has_access = [
@@ -1197,12 +1195,12 @@ class ProgramCourseEnrollmentOverviewView(
"""
Raises ``PermissionDenied`` if the user is not enrolled in the program with the given UUID.
"""
program_enrollments = ProgramEnrollment.objects.filter(
user_enrollment_qs = fetch_program_enrollments(
program_uuid=program_uuid,
user=user,
status='enrolled',
users={user},
program_enrollment_statuses={ProgramEnrollmentStatuses.ENROLLED},
)
if not program_enrollments:
if not user_enrollment_qs.exists():
raise PermissionDenied

View File

@@ -1,7 +1,7 @@
"""
Signal handlers for program enrollments
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import logging
@@ -9,12 +9,14 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from social_django.models import UserSocialAuth
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_MISC
from student.models import CourseEnrollmentException
from third_party_auth.models import SAMLProviderConfig
from .api import fetch_program_enrollments_by_student
from .models import ProgramEnrollment
logger = logging.getLogger(__name__)
@@ -39,7 +41,7 @@ def listen_for_social_auth_creation(sender, instance, created, **kwargs): # pyl
matriculate_learner(instance.user, instance.uid)
except Exception as e:
logger.warning(
u'Unable to link waiting enrollments for user %s, social auth creation failed: %s',
'Unable to link waiting enrollments for user %s, social auth creation failed: %s',
instance.user.id,
e,
)
@@ -61,24 +63,24 @@ def matriculate_learner(user, uid):
if not authorizing_org:
return
except (AttributeError, ValueError):
logger.info(u'Ignoring non-saml social auth entry for user=%s', user.id)
logger.info('Ignoring non-saml social auth entry for user=%s', user.id)
return
except SAMLProviderConfig.DoesNotExist:
logger.warning(
u'Got incoming social auth for provider=%s but no such provider exists', provider_slug
'Got incoming social auth for provider=%s but no such provider exists', provider_slug
)
return
except SAMLProviderConfig.MultipleObjectsReturned:
logger.warning(
u'Unable to activate waiting enrollments for user=%s.'
u' Multiple active SAML configurations found for slug=%s. Expected one.',
'Unable to activate waiting enrollments for user=%s.'
' Multiple active SAML configurations found for slug=%s. Expected one.',
user.id,
provider_slug)
return
incomplete_enrollments = ProgramEnrollment.objects.filter(
incomplete_enrollments = fetch_program_enrollments_by_student(
external_user_key=external_user_key,
user=None,
waiting_only=True,
).prefetch_related('program_course_enrollments')
for enrollment in incomplete_enrollments:
@@ -88,8 +90,8 @@ def matriculate_learner(user, uid):
continue
except (KeyError, TypeError):
logger.warning(
u'Failed to complete waiting enrollments for organization=%s.'
u' No catalog programs with matching authoring_organization exist.',
'Failed to complete waiting enrollments for organization=%s.'
' No catalog programs with matching authoring_organization exist.',
authorizing_org.short_name
)
continue
@@ -101,7 +103,7 @@ def matriculate_learner(user, uid):
program_course_enrollment.enroll(user)
except CourseEnrollmentException as e:
logger.warning(
u'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
user.id,
program_course_enrollment.id,
e,

View File

@@ -1,5 +1,5 @@
""" Tasks for program enrollments """
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import logging
from datetime import timedelta
@@ -31,21 +31,21 @@ def expire_waiting_enrollments(expiration_days):
for program_enrollment in program_enrollments:
program_enrollment_ids.append(program_enrollment.id)
log.info(
u'Found expired program_enrollment (id=%s) for program_uuid=%s',
'Found expired program_enrollment (id=%s) for program_uuid=%s',
program_enrollment.id,
program_enrollment.program_uuid,
)
for course_enrollment in program_enrollment.program_course_enrollments.all():
program_course_enrollment_ids.append(course_enrollment.id)
log.info(
u'Found expired program_course_enrollment (id=%s) for program_uuid=%s, course_key=%s',
'Found expired program_course_enrollment (id=%s) for program_uuid=%s, course_key=%s',
course_enrollment.id,
program_enrollment.program_uuid,
course_enrollment.course_key,
)
deleted_enrollments = program_enrollments.delete()
log.info(u'Removed %s expired records: %s', deleted_enrollments[0], deleted_enrollments[1])
log.info('Removed %s expired records: %s', deleted_enrollments[0], deleted_enrollments[1])
deleted_hist_program_enroll = ProgramEnrollment.historical_records.filter( # pylint: disable=no-member
id__in=program_enrollment_ids
@@ -54,10 +54,10 @@ def expire_waiting_enrollments(expiration_days):
id__in=program_course_enrollment_ids
).delete()
log.info(
u'Removed %s historical program_enrollment records with id in %s',
'Removed %s historical program_enrollment records with id in %s',
deleted_hist_program_enroll[0], program_enrollment_ids
)
log.info(
u'Removed %s historical program_course_enrollment records with id in %s',
'Removed %s historical program_course_enrollment records with id in %s',
deleted_hist_course_enroll[0], program_course_enrollment_ids
)

View File

@@ -11,7 +11,6 @@ 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 six.moves import range
from testfixtures import LogCapture
from course_modes.models import CourseMode
@@ -69,47 +68,6 @@ class ProgramEnrollmentModelTests(TestCase):
status='suspended',
)
def test_bulk_read_by_student_key(self):
curriculum_a = uuid4()
curriculum_b = uuid4()
enrollments = []
student_data = {}
for i in range(5):
# This will give us 4 program enrollments for self.program_uuid
# and 1 enrollment for self.other_program_uuid
user_curriculum = curriculum_b if i % 2 else curriculum_a
user_status = 'pending' if i % 2 else 'enrolled'
user_program = self.other_program_uuid if i == 4 else self.program_uuid
user_key = 'student-{}'.format(i)
enrollments.append(
ProgramEnrollment.objects.create(
user=None,
external_user_key=user_key,
program_uuid=user_program,
curriculum_uuid=user_curriculum,
status=user_status,
)
)
student_data[user_key] = {'curriculum_uuid': user_curriculum}
enrollment_records = ProgramEnrollment.bulk_read_by_student_key(self.program_uuid, student_data)
expected = {
'student-0': {'curriculum_uuid': curriculum_a, 'status': 'enrolled', 'program_uuid': self.program_uuid},
'student-1': {'curriculum_uuid': curriculum_b, 'status': 'pending', 'program_uuid': self.program_uuid},
'student-2': {'curriculum_uuid': curriculum_a, 'status': 'enrolled', 'program_uuid': self.program_uuid},
'student-3': {'curriculum_uuid': curriculum_b, 'status': 'pending', 'program_uuid': self.program_uuid},
}
assert expected == {
enrollment.external_user_key: {
'curriculum_uuid': enrollment.curriculum_uuid,
'status': enrollment.status,
'program_uuid': enrollment.program_uuid,
}
for enrollment in enrollment_records
}
def test_user_retirement(self):
"""
Test that the external_user_key is successfully retired for a user's program enrollments

View File

@@ -2,7 +2,7 @@
Test signal handlers for program_enrollments
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import mock
import pytest
@@ -312,7 +312,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
(
logger.name,
'WARNING',
u'Got incoming social auth for provider={} but no such provider exists'.format('abc')
'Got incoming social auth for provider={} but no such provider exists'.format('abc')
)
)
@@ -330,8 +330,8 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
error_template = (
u'Failed to complete waiting enrollments for organization={}.'
u' No catalog programs with matching authoring_organization exist.'
'Failed to complete waiting enrollments for organization={}.'
' No catalog programs with matching authoring_organization exist.'
)
log.check_present(
(
@@ -353,7 +353,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
error_template = u'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
error_template = 'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
log.check_present(
(
logger.name,
@@ -379,7 +379,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id),
)
error_template = u'Unable to link waiting enrollments for user {}, social auth creation failed: {}'
error_template = 'Unable to link waiting enrollments for user {}, social auth creation failed: {}'
log.check_present(
(
logger.name,

View File

@@ -1,7 +1,7 @@
"""
Unit tests for program_course_enrollments tasks
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
from datetime import timedelta
@@ -77,9 +77,9 @@ class ExpireWaitingEnrollmentsTest(TestCase):
with LogCapture(log.name) as log_capture:
expire_waiting_enrollments(60)
program_enrollment_message_tmpl = u'Found expired program_enrollment (id={}) for program_uuid={}'
program_enrollment_message_tmpl = 'Found expired program_enrollment (id={}) for program_uuid={}'
course_enrollment_message_tmpl = (
u'Found expired program_course_enrollment (id={}) for program_uuid={}, course_key={}'
'Found expired program_course_enrollment (id={}) for program_uuid={}, course_key={}'
)
log_capture.check_present(

View File

@@ -1,7 +1,7 @@
"""
Unit tests for program_enrollments utils.
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
from uuid import uuid4

View File

@@ -1,7 +1,7 @@
"""
utility functions for program enrollments
"""
from __future__ import absolute_import
from __future__ import absolute_import, unicode_literals
import logging
@@ -106,11 +106,11 @@ def get_provider_slug(organization):
try:
provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
except SAMLProviderConfig.DoesNotExist:
log.error(u'No SAML provider found for organization id [%s]', organization.id)
log.error('No SAML provider found for organization id [%s]', organization.id)
raise ProviderDoesNotExistException
except SAMLProviderConfig.MultipleObjectsReturned:
log.error(
u'Multiple active SAML configurations found for organization=%s. Expected one.',
'Multiple active SAML configurations found for organization=%s. Expected one.',
organization.short_name,
)
raise ProviderConfigurationException

View File

@@ -22,7 +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.link_program_enrollments import NO_PROGRAM_ENROLLMENT_TPL
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
@@ -521,5 +521,5 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
'program_uuid': self.program_uuid,
'text': text,
})
msg = NO_PROGRAM_ENROLLMENT_TPL.format(program_uuid=self.program_uuid, external_student_key=text)
msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(program_uuid=self.program_uuid, external_student_key=text)
self._assert_props_list('errors', [msg], response)

View File

@@ -10,7 +10,7 @@ from django.views.generic import View
from edxmako.shortcuts import render_to_response
from lms.djangoapps.support.decorators import require_support_permission
from lms.djangoapps.program_enrollments.link_program_enrollments import link_program_enrollments_to_lms_users
from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
TEMPLATE_PATH = 'support/link_program_enrollments.html'