From 65cee2cdb9da0ceee7e785f11d0524ea45e259c0 Mon Sep 17 00:00:00 2001 From: Arunmozhi Date: Thu, 3 Aug 2023 13:16:23 +0530 Subject: [PATCH] feat: allow enrollment into 'invite-only' courses via enrollment url This adds a new flag to the EnterpriseCustomer model that can be used to allow users to enroll into "Invitation Only" courses by visiting the enterprise enrollment URL without being explicityly invited by course instructors. Internal-Ref: https://tasks.opencraft.com/browse/BB-7619 --- enterprise/__init__.py | 2 +- enterprise/admin/forms.py | 1 + .../migrations/0156_auto_20230724_1611.py | 23 ++++ enterprise/models.py | 8 ++ enterprise/utils.py | 21 ++++ enterprise/views.py | 110 ++++++++++-------- tests/test_enterprise/test_utils.py | 21 ++++ .../views/test_course_enrollment_view.py | 52 +++++++++ tests/test_utilities.py | 1 + 9 files changed, 192 insertions(+), 47 deletions(-) create mode 100644 enterprise/migrations/0156_auto_20230724_1611.py diff --git a/enterprise/__init__.py b/enterprise/__init__.py index dfbb99e9e0..d9963d1cac 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,6 +2,6 @@ Your project description goes here. """ -__version__ = "3.42.9" +__version__ = "3.42.10" default_app_config = "enterprise.apps.EnterpriseConfig" diff --git a/enterprise/admin/forms.py b/enterprise/admin/forms.py index e69225c068..7cf138c808 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -399,6 +399,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/migrations/0156_auto_20230724_1611.py b/enterprise/migrations/0156_auto_20230724_1611.py new file mode 100644 index 0000000000..8ffbb0fd45 --- /dev/null +++ b/enterprise/migrations/0156_auto_20230724_1611.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2023-07-24 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0155_auto_20230706_0810'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=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."), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=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."), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index d2bfac1187..63eebc4613 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -391,6 +391,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 2e2fef6396..d1898240c9 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 @@ -2326,3 +2328,22 @@ 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 adb079aca6..a319de4bc6 100644 --- a/enterprise/views.py +++ b/enterprise/views.py @@ -62,6 +62,7 @@ CourseEnrollmentPermissionError, NotConnectedToOpenEdX, clean_html_for_template_rendering, + ensure_course_enrollment_is_allowed, filter_audit_course_modes, format_price, get_active_course_runs, @@ -1661,12 +1662,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 the 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, @@ -1711,51 +1717,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 @@ -1770,6 +1737,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/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 9c7cc6fda4..79eec4abc3 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -14,6 +14,7 @@ from enterprise.models import EnterpriseCourseEnrollment from enterprise.utils import ( enroll_licensed_users_in_courses, + ensure_course_enrollment_is_allowed, get_idiff_list, get_platform_logo_url, hide_price_when_zero, @@ -362,3 +363,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 2368d2c7d0..6814452692 100644 --- a/tests/test_enterprise/views/test_course_enrollment_view.py +++ b/tests/test_enterprise/views/test_course_enrollment_view.py @@ -1615,6 +1615,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.views.render', side_effect=fake_render) @mock.patch('enterprise.api_client.lms.embargo_api') @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') diff --git a/tests/test_utilities.py b/tests/test_utilities.py index cfa1f71bd2..197b352f95 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -147,6 +147,7 @@ def setUp(self): "reply_to", "hide_labor_market_data", "hide_course_price_when_zero", + "allow_enrollment_in_invite_only_courses", ] ), (