From 3f5667ac557035394a8da38adc9e8b5cb640a9ee Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Fri, 15 Mar 2024 09:45:59 -0700 Subject: [PATCH] feat: pass force_enrollment when bulk enrolling learners A `force_enrollment` boolean flag has been added to the "enrollment info" dict fed into the bulk enrollment endpoint. This enables consumers of the enterprise bulk enrollment endpoint to force specific enrollments even after the enrollment deadline has passed for the course. ENT-8525 --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- .../api/v1/views/enterprise_customer.py | 12 +- enterprise/utils.py | 7 +- tests/test_enterprise/test_utils.py | 114 ++++++++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c7209b4284..c53f65a767 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.11] +--------- +* feat: pass force_enrollment when bulk enrolling learners + [4.13.10] --------- * fix: remove filter to debug failing transmissions diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ae11b10a58..2d9498e47f 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.10" +__version__ = "4.13.11" diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 1685304d17..3046b30660 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -171,9 +171,15 @@ def enroll_learners_in_courses(self, request, pk): Parameters: enrollments_info (list of dicts): an array of dictionaries, each containing the necessary information to create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain - a user email (or user_id), a course run key, and either a UUID of the license that the learner is using - to enroll with or a transaction ID related to Executive Education the enrollment. `licenses_info` is - also accepted as a body param name. + the following keys: + + * 'user_id' OR 'email': Either unique identifier describing the user to enroll. + * 'course_run_key': The course to enroll into. + * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. `license_uuid` refers to + subscription licenses, and `transaction_id` refers to Learner Credit transactions. + * 'force_enrollment' (bool, optional): Enroll even if enrollment deadline is expired (default False). + + `licenses_info` is also accepted as a body param name. Example:: diff --git a/enterprise/utils.py b/enterprise/utils.py index 631eeefd48..f0f47d38b5 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -1807,6 +1807,7 @@ def customer_admin_enroll_user_with_status( enrollment_source=None, license_uuid=None, transaction_id=None, + force_enrollment=False, ): """ For use with bulk enrollment, or any use case of admin enrolling a user @@ -1848,6 +1849,7 @@ def customer_admin_enroll_user_with_status( course_mode, is_active=True, enterprise_uuid=enterprise_customer.uuid, + force_enrollment=force_enrollment, ) succeeded = True LOGGER.info("Successfully enrolled user %s in course %s", user.id, course_id) @@ -1987,6 +1989,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis * 'course_run_key': The course to enroll into. * 'course_mode': The course mode. * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. + * 'force_enrollment' (bool, optional): Enroll user even enrollment deadline is expired (default False). Example:: @@ -2037,6 +2040,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis license_uuid = subsidy_user_info.get('license_uuid') transaction_id = subsidy_user_info.get('transaction_id') activation_link = subsidy_user_info.get('activation_link') + force_enrollment = subsidy_user_info.get('force_enrollment', False) if user_id and user_email: user = User.objects.filter(id=subsidy_user_info['user_id']).first() @@ -2066,7 +2070,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis course_run_key, enrollment_source, license_uuid, - transaction_id + transaction_id, + force_enrollment=force_enrollment, ) if succeeded: success_dict = { diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 9dc3cac117..fcc2fa793f 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -4,6 +4,7 @@ import unittest from datetime import timedelta from unittest import mock +from unittest.mock import call from urllib.parse import quote, urlencode import ddt @@ -325,6 +326,119 @@ def test_enroll_subsidy_users_in_courses_with_user_id_succeeds( ) self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') + def test_enroll_subsidy_users_in_courses_with_force_enrollment( + self, + mock_update_or_create_enrollment, + ): + """ + """ + self.create_user() + another_user_1 = factories.UserFactory(is_active=True) + another_user_2 = factories.UserFactory(is_active=True) + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + licensed_users_info = [ + { + # Should succeed with force_enrollment passed as False under the hood. + 'user_id': self.user.id, + 'course_run_key': 'course-key-1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should also succeed with force_enrollment passed as False. + 'user_id': another_user_1.id, + 'course_run_key': 'course-key-2', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': False, + }, + { + # Should succeed with force_enrollment passed as True. + 'user_id': another_user_2.id, + 'course_run_key': 'course-key-3', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': True, + }, + ] + + mock_update_or_create_enrollment.return_value = True + + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) + self.assertEqual( + { + 'pending': [], + 'successes': [ + { + 'user_id': self.user.id, + 'email': self.user.email, + 'course_run_key': 'course-key-1', + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=self.user.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_1.id, + 'email': another_user_1.email, + 'course_run_key': 'course-key-2', + 'user': another_user_1, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_1.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_2.id, + 'email': another_user_2.email, + 'course_run_key': 'course-key-3', + 'user': another_user_2, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_2.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + ], + 'failures': [], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 3) + assert mock_update_or_create_enrollment.mock_calls == [ + call( + self.user.username, + 'course-key-1', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_1.username, + 'course-key-2', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_2.username, + 'course-key-3', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=True, + ), + ] + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') def test_enroll_subsidy_users_in_courses_user_identifier_failures( self,