diff --git a/enterprise/api/v1/views.py b/enterprise/api/v1/views.py index eddcbab385..825735c155 100644 --- a/enterprise/api/v1/views.py +++ b/enterprise/api/v1/views.py @@ -75,6 +75,7 @@ NotConnectedToOpenEdX, enroll_subsidy_users_in_courses, get_best_mode_from_course_key, + get_course_details_from_course_keys, get_enterprise_customer, get_request_value, track_enrollment, @@ -328,6 +329,8 @@ def enroll_learners_in_courses(self, request, pk): for course_run in course_runs_modes: course_runs_modes[course_run] = get_best_mode_from_course_key(course_run) + course_details = get_course_details_from_course_keys(course_runs_modes.keys()) + emails = set() for info in enrollments_info: @@ -341,6 +344,7 @@ def enroll_learners_in_courses(self, request, pk): else: emails.add(info['email']) info['course_mode'] = course_runs_modes[info['course_run_key']] + info['invitation_only'] = course_details[info['course_run_key']].invitation_only for email in emails: try: diff --git a/enterprise/utils.py b/enterprise/utils.py index fb38358869..50445a49fb 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -57,6 +57,11 @@ CourseUserGroup = None CourseEnrollmentError = None +try: + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +except ImportError: + CourseOverview = None + try: from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollmentAllowed @@ -1979,6 +1984,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis user_id = subsidy_user_info.get('user_id') user_email = subsidy_user_info['email'].strip().lower() if 'email' in subsidy_user_info else None course_mode = subsidy_user_info.get('course_mode') + invitation_only = subsidy_user_info.get('invitation_only') course_run_key = subsidy_user_info.get('course_run_key') license_uuid = subsidy_user_info.get('license_uuid') transaction_id = subsidy_user_info.get('transaction_id') @@ -2005,6 +2011,12 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis enrollment_source = enterprise_enrollment_source_model().get_source( enterprise_enrollment_source_model().CUSTOMER_ADMIN ) + if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses: + CourseEnrollmentAllowed.objects.update_or_create( + course_id=course_run_key, + email=user.email, + ) + succeeded, created, source_uuid = customer_admin_enroll_user_with_status( enterprise_customer, user, @@ -2243,6 +2255,14 @@ def get_best_mode_from_course_key(course_key): return CourseModes.AUDIT +def get_course_details_from_course_keys(course_keys): + """ + Helper to get a mapping of course keys to course details. + """ + course_overviews = CourseOverview.objects.filter(id__in=course_keys) + return {str(course_overview.id): course_overview for course_overview in course_overviews} + + def parse_lms_api_datetime(datetime_string, datetime_format=LMS_API_DATETIME_FORMAT): """ Parse a received datetime into a timezone-aware, Python datetime object. diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 7883b078b9..2f75d51cd9 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -4004,6 +4004,7 @@ def _create_user_and_enterprise_customer(self, username, password): }, ) @ddt.unpack + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @mock.patch('enterprise.api.v1.views.track_enrollment') @mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners") @@ -4014,6 +4015,7 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( mock_notify_task, mock_track_enroll, mock_get_course_mode, + mock_get_course_details, body, expected_code, expected_response, @@ -4034,6 +4036,7 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False self.assertEqual(len(PendingEnrollment.objects.all()), 0) @@ -4066,6 +4069,7 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( # no notifications to be sent unless 'notify' specifically asked for in payload mock_notify_task.assert_not_called() + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @mock.patch('enterprise.api.v1.views.track_enrollment') @mock.patch('enterprise.models.EnterpriseCustomer.notify_enrolled_learners') @@ -4076,6 +4080,7 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( mock_notify_task, mock_track_enroll, mock_get_course_mode, + mock_get_course_details, ): """ Tests the bulk enrollment endpoint at enroll_learners_in_courses. @@ -4083,6 +4088,7 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( This tests the case where existing users are supplied, so the enrollments are fulfilled rather than pending. """ mock_customer_admin_enroll_user.return_value = True + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False user_one = factories.UserFactory(is_active=True) user_two = factories.UserFactory(is_active=True) @@ -4153,6 +4159,7 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( # no notifications to be sent unless 'notify' specifically asked for in payload mock_notify_task.assert_not_called() + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @mock.patch('enterprise.api.v1.views.track_enrollment') @mock.patch('enterprise.models.EnterpriseCustomer.notify_enrolled_learners') @@ -4161,6 +4168,7 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( mock_notify_task, mock_track_enroll, mock_get_course_mode, + mock_get_course_details, ): """ Tests the bulk enrollment endpoint at enroll_learners_in_courses. @@ -4177,6 +4185,7 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False self.assertEqual(len(PendingEnrollment.objects.all()), 0) body = { @@ -4243,12 +4252,14 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( }, ) @ddt.unpack + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @mock.patch("enterprise.utils.lms_enroll_user_in_course") def test_bulk_enrollment_includes_fulfillment_source_uuid( self, mock_platform_enrollment, mock_get_course_mode, + mock_get_course_details, body, fulfillment_source, ): @@ -4265,6 +4276,7 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( permission = Permission.objects.get(name='Can add Enterprise Customer') user.user_permissions.add(permission) mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False enrollment_url = reverse( 'enterprise-customer-enroll-learners-in-courses', @@ -4351,6 +4363,7 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( }, ) @ddt.unpack + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') @mock.patch('enterprise.api.v1.views.track_enrollment') @mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners") @@ -4359,6 +4372,7 @@ def test_bulk_enrollment_with_notification( mock_notify_task, mock_track_enroll, mock_get_course_mode, + mock_get_course_details, body, expected_code, expected_response, @@ -4378,6 +4392,7 @@ def test_bulk_enrollment_with_notification( permission = Permission.objects.get(name='Can add Enterprise Customer') self.user.user_permissions.add(permission) mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False self.assertEqual(len(PendingEnrollment.objects.all()), 0) @@ -4425,12 +4440,72 @@ def _make_call(course_run, enrolled_learners): mock_notify_task.assert_has_calls(mock_calls, any_order=True) + @mock.patch('enterprise.utils.CourseEnrollmentAllowed') + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') + @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') + @mock.patch('enterprise.utils.lms_enroll_user_in_course') + def test_bulk_enrollment_invitation_only( + self, + mock_platform_enrollment, + mock_get_course_mode, + mock_get_course_details, + mock_cea, + ): + """ + Tests that bulk enrollment endpoint creates CourseEnrollmentAllowed object when enterprise customer allows + enrollment in invitation only courses and the course is invitation only. + """ + mock_platform_enrollment.return_value = True + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = True + mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + + user, enterprise_customer = self._create_user_and_enterprise_customer("abc@test.com", "test_password") + course_id = 'course-v1:edX+DemoX+Demo_Course' + body = { + 'enrollments_info': [ + { + 'user_id': user.id, + 'course_run_key': course_id, + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + }, + ] + } + + def enroll(): + self.client.post( + settings.TEST_SERVER + reverse( + 'enterprise-customer-enroll-learners-in-courses', (enterprise_customer.uuid,) + ), + data=json.dumps(body), + content_type='application/json', + ) + + enroll() + mock_cea.objects.update_or_create.assert_not_called() + + enterprise_customer.allow_enrollment_in_invite_only_courses = True + enterprise_customer.save() + + enroll() + mock_cea.objects.update_or_create.assert_called_with( + course_id=course_id, + email=user.email + ) + + @mock.patch('enterprise.api.v1.views.get_course_details_from_course_keys') @mock.patch('enterprise.api.v1.views.enroll_subsidy_users_in_courses') @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') - def test_enroll_learners_in_courses_partial_failure(self, mock_get_course_mode, mock_enroll_user): + def test_enroll_learners_in_courses_partial_failure( + self, + mock_get_course_mode, + mock_enroll_user, + mock_get_course_details, + ): """ Tests that bulk users bulk enrollment endpoint properly handles partial failures. """ + mock_get_course_details.return_value.__getitem__.return_value.invitation_only = True + ent_customer = factories.EnterpriseCustomerFactory( uuid=FAKE_UUIDS[0], name="test_enterprise"