Merge pull request #32595 from openedx/ea/ent-7031

feature: upgrade course enrollment from audit to verified
This commit is contained in:
Emily Rosario-Aquin
2023-07-18 08:04:58 -05:00
committed by GitHub
7 changed files with 407 additions and 73 deletions

View File

@@ -52,3 +52,7 @@ class EnrollmentApiLoadError(CourseEnrollmentError):
class InvalidEnrollmentAttribute(CourseEnrollmentError):
"""Enrollment Attributes could not be validated"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class CourseEnrollmentNotUpdatableError(CourseEnrollmentError):
"""The requested enrollment could not be updated."""

View File

@@ -1,11 +1,12 @@
"""
Test the enterprise support utils.
"""
import ddt
from unittest import mock
from unittest.case import TestCase
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.course_groups.cohorts import CourseUserGroup
@@ -15,8 +16,10 @@ from openedx.features.enterprise_support.enrollments.exceptions import (
CourseIdMissingException,
UserDoesNotExistException
)
from openedx.features.enterprise_support.enrollments.utils import lms_enroll_user_in_course
from openedx.features.enterprise_support.enrollments.utils import (
lms_enroll_user_in_course,
lms_update_or_create_enrollment,
)
COURSE_STRING = 'course-v1:OpenEdX+OutlineCourse+Run3'
ENTERPRISE_UUID = 'enterprise_uuid'
COURSE_ID = CourseKey.from_string(COURSE_STRING)
@@ -26,6 +29,7 @@ COURSE_MODE = 'verified'
@skip_unless_lms
@ddt.ddt
class EnrollmentUtilsTest(TestCase):
"""
Test enterprise support utils.
@@ -37,104 +41,222 @@ class EnrollmentUtilsTest(TestCase):
self.a_user.id = USER_ID
self.a_user.username = USERNAME
def test_validation_of_inputs_course_id(self):
with self.assertRaises(CourseIdMissingException):
lms_enroll_user_in_course(USERNAME, None, COURSE_MODE, ENTERPRISE_UUID)
def run_test_with_setting(
self,
setting,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false,
):
"""
Run a test with a setting.
"""
with override_settings(
ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting
):
if setting:
return test_function_true(mock_update_create_enroll)
return test_function_false(mock_enroll_user)
def test_validation_of_inputs_user_not_provided(self):
with self.assertRaises(UserDoesNotExistException):
lms_enroll_user_in_course(
None,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID,
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@ddt.data(True, False)
def test_validation_of_inputs_course_id(self, setting_value, mock_update_create_enroll, mock_enroll_user):
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, None, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(USERNAME, None, COURSE_MODE, ENTERPRISE_UUID)
with self.assertRaises(CourseIdMissingException):
self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@ddt.data(True, False)
def test_validation_of_inputs_user_not_provided(self, setting_value, mock_update_create_enroll, mock_enroll_user):
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
None, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(None, COURSE_ID, COURSE_MODE, ENTERPRISE_UUID)
with self.assertRaises(UserDoesNotExistException):
self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
def test_validation_of_inputs_user_not_found(self, mock_tx, mock_user_model):
@ddt.data(True, False)
def test_validation_of_inputs_user_not_found(
self,
setting_value,
mock_tx,
mock_user_model,
mock_update_create_enroll,
mock_enroll_user
):
mock_tx.return_value.atomic.side_effect = None
mock_user_model.side_effect = ObjectDoesNotExist()
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID
)
with self.assertRaises(UserDoesNotExistException):
lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID,
self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
@ddt.data(True, False)
def test_course_enrollment_error_raises(
self,
setting_value,
mock_tx,
mock_user_model,
mock_get_enrollment_api,
mock_add_enrollment_api,
mock_update_create_enroll,
mock_enroll_user
):
enrollment_response = {'mode': COURSE_MODE, 'is_active': True}
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID
)
mock_add_enrollment_api.side_effect = CourseEnrollmentError("test")
mock_tx.return_value.atomic.side_effect = None
mock_get_enrollment_api.return_value = enrollment_response
mock_user_model.return_value = self.a_user
enrollment_response = {'mode': COURSE_MODE, 'is_active': True} if not setting_value else None
mock_get_enrollment_api.return_value = enrollment_response
with self.assertRaises(CourseEnrollmentError):
lms_enroll_user_in_course(USERNAME, COURSE_ID, COURSE_MODE, ENTERPRISE_UUID)
mock_get_enrollment_api.assert_called_once()
self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
mock_get_enrollment_api.assert_called_once_with(USERNAME, str(COURSE_ID))
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
@ddt.data(True, False)
def test_course_group_error_raises(
self,
setting_value,
mock_tx,
mock_user_model,
mock_get_enrollment_api,
mock_add_enrollment_api,
mock_update_create_enroll,
mock_enroll_user
):
enrollment_response = {'mode': COURSE_MODE, 'is_active': True}
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID
)
mock_add_enrollment_api.side_effect = CourseUserGroup.DoesNotExist()
mock_tx.return_value.atomic.side_effect = None
mock_get_enrollment_api.return_value = enrollment_response
mock_user_model.return_value = self.a_user
enrollment_response = {'mode': COURSE_MODE, 'is_active': True} if not setting_value else None
mock_get_enrollment_api.return_value = enrollment_response
with self.assertRaises(CourseUserGroup.DoesNotExist):
lms_enroll_user_in_course(USERNAME, COURSE_ID, COURSE_MODE, ENTERPRISE_UUID)
mock_get_enrollment_api.assert_called_once()
self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
mock_get_enrollment_api.assert_called_once_with(USERNAME, str(COURSE_ID))
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
@ddt.data(True, False)
def test_calls_enrollment_and_cohort_apis(
self,
setting,
mock_tx,
mock_user_model,
mock_get_enrollment_api,
mock_add_enrollment_api,
mock_update_create_enroll,
mock_enroll_user,
):
expected_response = {'a': 'value'}
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID
)
expected_response = {'mode': COURSE_MODE, 'is_active': True}
enrollment_response = {'mode': COURSE_MODE, 'is_active': True}
mock_add_enrollment_api.return_value = expected_response
mock_tx.return_value.atomic.side_effect = None
mock_get_enrollment_api.return_value = enrollment_response
mock_user_model.return_value = self.a_user
response = lms_enroll_user_in_course(USERNAME, COURSE_ID, COURSE_MODE, ENTERPRISE_UUID)
if setting:
mock_get_enrollment_api.return_value = None
else:
mock_get_enrollment_api.return_value = enrollment_response
response = self.run_test_with_setting(
setting,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
assert response == expected_response
mock_add_enrollment_api.assert_called_once_with(
USERNAME,
@@ -144,22 +266,35 @@ class EnrollmentUtilsTest(TestCase):
enrollment_attributes=None,
enterprise_uuid=ENTERPRISE_UUID,
)
mock_get_enrollment_api.assert_called_once_with(USERNAME, str(COURSE_ID))
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_enroll_user_in_course')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.lms_update_or_create_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
@ddt.data(True, False)
def test_existing_enrollment_does_not_fail(
self,
setting_value,
mock_tx,
mock_user_model,
mock_get_enrollment_api,
mock_add_enrollment_api,
mock_update_create_enroll,
mock_enroll_user,
):
expected_response = None
test_function_true = lambda mock_fn: lms_update_or_create_enrollment(
USERNAME, COURSE_ID, COURSE_MODE, is_active=True, enterprise_uuid=ENTERPRISE_UUID
)
test_function_false = lambda mock_fn: lms_enroll_user_in_course(
USERNAME,
COURSE_ID,
COURSE_MODE,
ENTERPRISE_UUID
)
expected_response = {'mode': COURSE_MODE, 'is_active': True}
enrollment_response = {'mode': COURSE_MODE, 'is_active': True}
mock_add_enrollment_api.side_effect = CourseEnrollmentExistsError("test", {})
@@ -169,16 +304,97 @@ class EnrollmentUtilsTest(TestCase):
mock_user_model.return_value = self.a_user
response = lms_enroll_user_in_course(USERNAME, COURSE_ID, COURSE_MODE, ENTERPRISE_UUID)
response = self.run_test_with_setting(
setting_value,
mock_update_create_enroll,
mock_enroll_user,
test_function_true,
test_function_false
)
if setting_value:
mock_add_enrollment_api.assert_not_called()
assert response == expected_response
else:
mock_add_enrollment_api.assert_called_once_with(
USERNAME,
str(COURSE_ID),
mode=COURSE_MODE,
is_active=True,
enrollment_attributes=None,
enterprise_uuid=ENTERPRISE_UUID,
)
assert response is None
mock_get_enrollment_api.assert_called_once()
assert response == expected_response
mock_add_enrollment_api.assert_called_once_with(
USERNAME,
str(COURSE_ID),
mode=COURSE_MODE,
is_active=True,
enrollment_attributes=None,
enterprise_uuid=ENTERPRISE_UUID,
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.update_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
def test_upgrade_user_enrollment_mode(
self,
mock_tx,
mock_user_model,
mock_add_enrollment_api,
mock_get_enrollment_api,
mock_update_enrollment_api,
):
enrollment_response = {'mode': COURSE_MODE, 'is_active': True}
mock_get_enrollment_api.return_value = {
'mode': 'audit',
'is_active': True,
}
mock_update_enrollment_api.return_value = {
'mode': 'verified',
'is_active': True,
}
mock_tx.return_value.atomic.side_effect = None
mock_user_model.return_value = self.a_user
upgraded_enrollment = lms_update_or_create_enrollment(
USERNAME, COURSE_ID, desired_mode=COURSE_MODE, is_active=True
)
assert upgraded_enrollment == enrollment_response
mock_update_enrollment_api.assert_called_once_with(
USERNAME,
str(COURSE_ID),
mode='verified',
is_active=True,
enrollment_attributes=None,
)
mock_get_enrollment_api.assert_called_once_with(USERNAME, str(COURSE_ID))
mock_add_enrollment_api.assert_not_called()
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.update_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.get_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.enrollment_api.add_enrollment')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.User.objects.get')
@mock.patch('openedx.features.enterprise_support.enrollments.utils.transaction')
def test_upgrade_user_enrollment_mode_already_verified(
self,
mock_tx,
mock_user_model,
mock_add_enrollment_api,
mock_get_enrollment_api,
mock_update_enrollment_api,
):
existing_enrollment = {
'mode': 'verified',
'is_active': True,
}
mock_get_enrollment_api.return_value = existing_enrollment
mock_tx.return_value.atomic.side_effect = None
mock_user_model.return_value = self.a_user
upgraded_enrollment = lms_update_or_create_enrollment(
USERNAME, COURSE_ID, desired_mode='verified', is_active=True
)
assert upgraded_enrollment == existing_enrollment
mock_update_enrollment_api.assert_not_called()
mock_get_enrollment_api.assert_called_once()
mock_add_enrollment_api.assert_not_called()

View File

@@ -8,7 +8,11 @@ from django.db import transaction
from common.djangoapps.student.models import User
from openedx.core.djangoapps.enrollments import api as enrollment_api
from openedx.core.djangoapps.enrollments.errors import CourseEnrollmentError, CourseEnrollmentExistsError
from openedx.core.djangoapps.enrollments.errors import (
CourseEnrollmentError,
CourseEnrollmentExistsError,
CourseEnrollmentNotUpdatableError,
)
from openedx.core.lib.log_utils import audit_log
from openedx.features.enterprise_support.enrollments.exceptions import (
CourseIdMissingException,
@@ -26,25 +30,7 @@ def lms_enroll_user_in_course(
is_active=True,
):
"""
Enrollment function meant to be called by edx-enterprise to replace the
current uses of the EnrollmentApiClient
The REST enrollment endpoint may also eventually also want to reuse this function
since it's a subset of what the endpoint handles
Unlike the REST endpoint, this function does not check for enterprise enabled, or user api key
permissions etc. Those concerns are still going to be used by REST endpoint but this function
is meant for use from within edx-enterprise hence already presume such privileges.
Arguments:
- username (str): User name
- course_id (obj) : Course key obtained using CourseKey.from_string(course_id_input)
- mode (CourseMode): course mode
- enterprise_uuid (str): id to identify the enterprise to enroll under
- is_active (bool): Optional. A Boolean value that indicates whether the
enrollment is to be set to inactive (if False). Usually we want a True if enrolling anew.
Returns: A serializable dictionary of the new course enrollment. If it hits
`CourseEnrollmentExistsError` then it logs the error and returns None.
Temporarily keeping the original enrollment function to help with deployment
"""
user = _validate_enrollment_inputs(username, course_id)
@@ -81,6 +67,134 @@ def lms_enroll_user_in_course(
)
def lms_update_or_create_enrollment(
username,
course_id,
desired_mode,
is_active,
enterprise_uuid=None,
):
"""
Update or create the user's course enrollment based on the existing enrollment mode.
If an enrollment exists and its mode is not equal to the desired mode,
then it updates the enrollment.
Otherwise, it creates a new enrollment.
Enrollment function meant to be called by edx-enterprise to replace the
current uses of the EnrollmentApiClient
The REST enrollment endpoint may also eventually also want to reuse this function
since it's a subset of what the endpoint handles
Unlike the REST endpoint, this function does not check for enterprise enabled, or user api key
permissions etc. Those concerns are still going to be used by REST endpoint but this function
is meant for use from within edx-enterprise hence already presume such privileges.
Arguments:
- username (str): User name
- course_id (obj) : Course key obtained using CourseKey.from_string(course_id_input)
- desired_mode (CourseMode): desired course mode
- is_active (bool): A Boolean value that indicates whether the
enrollment is to be set to inactive (if False). Usually we want a True if enrolling anew.
- enterprise_uuid (str): Optional. id to identify the enterprise to enroll under
Returns: A serializable dictionary of the new or updated course enrollment. If it hits
CourseEnrollmentError or CourseEnrollmentNotUpdatableError, it raises those exceptions.
In case of the add_enrollment call, it returns None if the enrollment already exists and
the desired_mode or is_active match the existing enrollment.
"""
user = _validate_enrollment_inputs(username, course_id)
current_enrollment = enrollment_api.get_enrollment(username, str(course_id))
response = None
if (
current_enrollment
and current_enrollment['mode'] == desired_mode
and current_enrollment['is_active'] == is_active
):
log.info(
"Existing enrollment [%s] for user [%s] matches desired enrollment. No action taken.",
current_enrollment,
username,
)
return current_enrollment
with transaction.atomic():
try:
if current_enrollment:
response = enrollment_api.update_enrollment(
username,
str(course_id),
mode=desired_mode,
is_active=is_active,
enrollment_attributes=None,
)
if not response or (
response['mode'] != desired_mode or
response['is_active'] != is_active
):
log.exception(
"An error occurred while updating the course enrollment for user "
"[%s]: course run = [%s], enterprise_uuid = [%s], is_active = [%s], ",
username,
course_id,
str(enterprise_uuid),
is_active,
)
raise CourseEnrollmentNotUpdatableError(
f"Unable to upgrade enrollment for user {username} "
"in course {course_id} to {desired_mode} mode."
"Response from update_enrollment: {response}"
)
else:
response = enrollment_api.add_enrollment(
username,
str(course_id),
mode=desired_mode,
is_active=is_active,
enrollment_attributes=None,
enterprise_uuid=enterprise_uuid,
)
if not response:
log.exception(
"An error occurred while creating the new course enrollment for user "
"[%s] in course run [%s]",
username,
course_id,
)
raise CourseEnrollmentError(
f"Unable to create enrollment for user {username} in course {course_id}."
)
except CourseEnrollmentExistsError as error:
# This will rarely be raised when we hit a race condition in adding a net-new enrollment
log.warning(
"An enrollment [%s] already exists for user [%s] in course run [%s].",
error.enrollment,
username,
course_id,
)
return None
except (CourseEnrollmentError, CourseEnrollmentNotUpdatableError) as error:
log.exception(
"Raising error [%s] for user "
"[%s]: course run = [%s], enterprise_uuid = [%s], is_active = [%s], ",
error,
username,
course_id,
str(enterprise_uuid),
is_active,
)
raise error
finally:
final_enrollment = response or current_enrollment
audit_log(
'enrollment_change_requested',
course_id=str(course_id),
requested_mode=desired_mode,
actual_mode=final_enrollment['mode'] if final_enrollment else None,
requested_activation=is_active,
actual_activation=final_enrollment['is_active'] if final_enrollment else None,
user_id=user.id
)
return response
def _validate_enrollment_inputs(username, course_id):
"""
Validates username and course_id.

View File

@@ -27,7 +27,7 @@ django-storages==1.9.1
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==4.0.0
edx-enterprise==4.0.1
# oauthlib>3.0.1 causes test failures ( also remove the django-oauth-toolkit constraint when this is fixed )
oauthlib==3.0.1

View File

@@ -484,7 +484,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
edx-enterprise==4.0.0
edx-enterprise==4.0.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in

View File

@@ -741,7 +741,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
edx-enterprise==4.0.0
edx-enterprise==4.0.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt

View File

@@ -590,7 +590,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
edx-enterprise==4.0.0
edx-enterprise==4.0.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt