diff --git a/enterprise/admin/forms.py b/enterprise/admin/forms.py index 4a9b0df322..7262a766d9 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -401,6 +401,7 @@ class Meta: "replace_sensitive_sso_username", "hide_course_original_price", "hide_course_price_when_zero", + "allow_enrollment_in_invite_only_courses", "enable_portal_code_management_screen", "enable_portal_subscription_management_screen", "enable_learner_portal", diff --git a/enterprise/models.py b/enterprise/models.py index c3eb3d29c8..a9475d8f9b 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -416,6 +416,14 @@ class Meta: help_text=_("Specify whether course cost should be hidden in the landing page when the final price is zero.") ) + allow_enrollment_in_invite_only_courses = models.BooleanField( + default=False, + help_text=_( + "Specifies if learners are allowed to enroll into courses marked as 'invitation-only', " + "when they attempt to enroll from the landing page." + ) + ) + @property def enterprise_customer_identity_provider(self): """ diff --git a/enterprise/utils.py b/enterprise/utils.py index 9796ea28d5..fb38358869 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -59,8 +59,10 @@ try: from common.djangoapps.course_modes.models import CourseMode + from common.djangoapps.student.models import CourseEnrollmentAllowed except ImportError: CourseMode = None + CourseEnrollmentAllowed = None try: from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -2313,3 +2315,23 @@ def hide_price_when_zero(enterprise_customer, course_modes): mode['title'] ) return course_modes + + +def ensure_course_enrollment_is_allowed(course_id, email, enrollment_api_client): + """ + Create a CourseEnrollmentAllowed object for invitation-only courses. + + Arguments: + course_id (str): ID of the course to allow enrollment + email (str): email of the user whose enrollment should be allowed + enrollment_api_client (:class:`enterprise.api_client.lms.EnrollmentApiClient`): Enrollment API Client + """ + if not CourseEnrollmentAllowed: + raise NotConnectedToOpenEdX() + + course_details = enrollment_api_client.get_course_details(course_id) + if course_details["invite_only"]: + CourseEnrollmentAllowed.objects.update_or_create( + course_id=course_id, + email=email, + ) diff --git a/enterprise/views.py b/enterprise/views.py index ba70f364cb..0fa0f7f97e 100644 --- a/enterprise/views.py +++ b/enterprise/views.py @@ -63,6 +63,7 @@ CourseEnrollmentPermissionError, NotConnectedToOpenEdX, clean_html_for_template_rendering, + ensure_course_enrollment_is_allowed, filter_audit_course_modes, format_price, get_active_course_runs, @@ -1681,12 +1682,17 @@ def post(self, request, enterprise_uuid, course_id): enterprise_customer.uuid, course_id=course_id ).consent_required() + + client = EnrollmentApiClient() + if enterprise_customer.allow_enrollment_in_invite_only_courses: + # Make sure a enrollment is allowed if the course is marked "invite-only" + ensure_course_enrollment_is_allowed(course_id, request.user.email, client) + if not selected_course_mode.get('premium') and not user_consent_needed: # For the audit course modes (audit, honor), where DSC is not # required, enroll the learner directly through enrollment API # client and redirect the learner to LMS courseware page. succeeded = True - client = EnrollmentApiClient() try: client.enroll_user_in_course( request.user.username, @@ -1731,51 +1737,12 @@ def post(self, request, enterprise_uuid, course_id): return redirect(LMS_COURSEWARE_URL.format(course_id=course_id)) if user_consent_needed: - # For the audit course modes (audit, honor) or for the premium - # course modes (Verified, Prof Ed) where DSC is required, redirect - # the learner to course specific DSC with enterprise UUID from - # there the learner will be directed to the ecommerce flow after - # providing DSC. - query_string_params = { - 'course_mode': selected_course_mode_name, - } - if enterprise_catalog_uuid: - query_string_params.update({'catalog': enterprise_catalog_uuid}) - - next_url = '{handle_consent_enrollment_url}?{query_string}'.format( - handle_consent_enrollment_url=reverse( - 'enterprise_handle_consent_enrollment', args=[enterprise_customer.uuid, course_id] - ), - query_string=urlencode(query_string_params) - ) - - failure_url = reverse('enterprise_course_run_enrollment_page', args=[enterprise_customer.uuid, course_id]) - if request.META['QUERY_STRING']: - # Preserve all querystring parameters in the request to build - # failure url, so that learner views the same enterprise course - # enrollment page (after redirect) as for the first time. - # Since this is a POST view so use `request.META` to get - # querystring instead of `request.GET`. - # https://docs.djangoproject.com/en/1.11/ref/request-response/#django.http.HttpRequest.META - failure_url = '{course_enrollment_url}?{query_string}'.format( - course_enrollment_url=reverse( - 'enterprise_course_run_enrollment_page', args=[enterprise_customer.uuid, course_id] - ), - query_string=request.META['QUERY_STRING'] - ) - - return redirect( - '{grant_data_sharing_url}?{params}'.format( - grant_data_sharing_url=reverse('grant_data_sharing_permissions'), - params=urlencode( - { - 'next': next_url, - 'failure_url': failure_url, - 'enterprise_customer_uuid': enterprise_customer.uuid, - 'course_id': course_id, - } - ) - ) + return self._handle_user_consent_flow( + request, + enterprise_customer, + enterprise_catalog_uuid, + course_id, + selected_course_mode_name ) # For the premium course modes (Verified, Prof Ed) where DSC is @@ -1790,6 +1757,57 @@ def post(self, request, enterprise_uuid, course_id): return redirect(premium_flow) + @staticmethod + def _handle_user_consent_flow(request, enterprise_customer, enterprise_catalog_uuid, course_id, course_mode): + """ + For the audit course modes (audit, honor) or for the premium + course modes (Verified, Prof Ed) where DSC is required, redirect + the learner to course specific DSC with enterprise UUID from + there the learner will be directed to the ecommerce flow after + providing DSC. + """ + query_string_params = { + 'course_mode': course_mode, + } + if enterprise_catalog_uuid: + query_string_params.update({'catalog': enterprise_catalog_uuid}) + + next_url = '{handle_consent_enrollment_url}?{query_string}'.format( + handle_consent_enrollment_url=reverse( + 'enterprise_handle_consent_enrollment', args=[enterprise_customer.uuid, course_id] + ), + query_string=urlencode(query_string_params) + ) + + failure_url = reverse('enterprise_course_run_enrollment_page', args=[enterprise_customer.uuid, course_id]) + if request.META['QUERY_STRING']: + # Preserve all querystring parameters in the request to build + # failure url, so that learner views the same enterprise course + # enrollment page (after redirect) as for the first time. + # Since this is a POST view so use `request.META` to get + # querystring instead of `request.GET`. + # https://docs.djangoproject.com/en/1.11/ref/request-response/#django.http.HttpRequest.META + failure_url = '{course_enrollment_url}?{query_string}'.format( + course_enrollment_url=reverse( + 'enterprise_course_run_enrollment_page', args=[enterprise_customer.uuid, course_id] + ), + query_string=request.META['QUERY_STRING'] + ) + + return redirect( + '{grant_data_sharing_url}?{params}'.format( + grant_data_sharing_url=reverse('grant_data_sharing_permissions'), + params=urlencode( + { + 'next': next_url, + 'failure_url': failure_url, + 'enterprise_customer_uuid': enterprise_customer.uuid, + 'course_id': course_id, + } + ) + ) + ) + @method_decorator(enterprise_login_required) @method_decorator(force_fresh_session) def get(self, request, enterprise_uuid, course_id): diff --git a/requirements/ci.txt b/requirements/ci.txt index 70f158255d..2bada4b3ec 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -8,7 +8,7 @@ certifi==2022.9.24 # via requests charset-normalizer==2.1.1 # via requests -codecov==2.1.12 +codecov==2.1.13 # via -r requirements/ci.in coverage==6.5.0 # via codecov diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index f222617249..2b3ef75a4e 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -14,6 +14,7 @@ from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment from enterprise.utils import ( enroll_subsidy_users_in_courses, + ensure_course_enrollment_is_allowed, get_idiff_list, get_platform_logo_url, hide_price_when_zero, @@ -520,3 +521,23 @@ def test_hide_course_price_when_zero(self, hide_price): else: self.assertEqual(zero_modes, processed_zero_modes) self.assertEqual(non_zero_modes, processed_non_zero_modes) + + @ddt.data(True, False) + @mock.patch("enterprise.utils.CourseEnrollmentAllowed") + def test_ensure_course_enrollment_is_allowed(self, invite_only, mock_cea): + """ + Test that the CourseEnrollmentAllowed is created only for the "invite_only" courses. + """ + self.create_user() + mock_enrollment_api = mock.Mock() + mock_enrollment_api.get_course_details.return_value = {"invite_only": invite_only} + + ensure_course_enrollment_is_allowed("test-course-id", self.user.email, mock_enrollment_api) + + if invite_only: + mock_cea.objects.update_or_create.assert_called_with( + course_id="test-course-id", + email=self.user.email + ) + else: + mock_cea.objects.update_or_create.assert_not_called() diff --git a/tests/test_enterprise/views/test_course_enrollment_view.py b/tests/test_enterprise/views/test_course_enrollment_view.py index cd26459f01..8ed1819d5a 100644 --- a/tests/test_enterprise/views/test_course_enrollment_view.py +++ b/tests/test_enterprise/views/test_course_enrollment_view.py @@ -1618,6 +1618,58 @@ def test_post_course_specific_enrollment_view_premium_mode( fetch_redirect_response=False ) + @mock.patch('enterprise.views.render', side_effect=fake_render) + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + @mock.patch('enterprise.views.EnrollmentApiClient') + @mock.patch('enterprise.views.get_data_sharing_consent') + @mock.patch('enterprise.utils.Registry') + @mock.patch('enterprise.utils.CourseEnrollmentAllowed') + def test_post_course_specific_enrollment_view_invite_only_courses( + self, + mock_cea, + registry_mock, + get_data_sharing_consent_mock, + enrollment_api_client_mock, + catalog_api_client_mock, + *args + ): + course_id = self.demo_course_id + get_data_sharing_consent_mock.return_value = mock.MagicMock(consent_required=mock.MagicMock(return_value=False)) + setup_course_catalog_api_client_mock(catalog_api_client_mock) + self._setup_enrollment_client(enrollment_api_client_mock) + enrollment_api_client_mock.return_value.get_course_details.return_value = {"invite_only": True} + + enterprise_customer = EnterpriseCustomerFactory( + name='Starfleet Academy', + enable_data_sharing_consent=False, + enable_audit_enrollment=False, + allow_enrollment_in_invite_only_courses=True, + ) + EnterpriseCustomerCatalogFactory(enterprise_customer=enterprise_customer) + self._setup_registry_mock(registry_mock, self.provider_id) + EnterpriseCustomerIdentityProviderFactory(provider_id=self.provider_id, enterprise_customer=enterprise_customer) + self._login() + course_enrollment_page_url = self._append_fresh_login_param( + reverse( + 'enterprise_course_run_enrollment_page', + args=[enterprise_customer.uuid, course_id], + ) + ) + enterprise_catalog_uuid = str(enterprise_customer.enterprise_customer_catalogs.first().uuid) + + response = self.client.post( + course_enrollment_page_url, { + 'course_mode': 'professional', + 'catalog': enterprise_catalog_uuid + } + ) + + mock_cea.objects.update_or_create.assert_called_with( + course_id=course_id, + email=self.user.email + ) + assert response.status_code == 302 + @mock.patch('enterprise.api_client.ecommerce.configuration_helpers') @mock.patch('enterprise.views.render', side_effect=fake_render) @mock.patch('enterprise.api_client.lms.embargo_api') diff --git a/tests/test_utilities.py b/tests/test_utilities.py index b38fa1b7e3..7bd9affb94 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -134,6 +134,7 @@ def setUp(self): "uuid", "name", "slug", + "allow_enrollment_in_invite_only_courses", "active", "country", "invite_keys",