Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cherry-pick edx-enterprise customizations to Quince [BB-8368] #13

Merged
Prev Previous commit
Next Next commit
temp: add utility function to add CEA objects
feat: adds allow invite only enrollment flag

feat: adds allow invite only enrollment flag

feat: create cea only when customer has invite-only enrollments enabled

fix: simplify the cea creation logic, update tests

fix: remove a stray empty line

feat: adds the invite-only flag to customer admin

fix: typo in the fuction docstring

Co-authored-by: Piotr Surowiec <piotr@surowiec.it>

refactor: convert the user consent flow handler method to static

fix: move migrations to avoid conflicts

feat: add typing to the ensure cea utility method

Revert "feat: add typing to the ensure cea utility method"

This reverts commit b6b2f25.

refactor: rename the migration with a custom name instead of the auto one

fix: remove typing that breaks causes import failures

chore: move the DB migration to latest version

-----------------------

Additionally includes this fix:
https://tasks.opencraft.com/browse/BB-7619?focusedCommentId=282911&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-282911
tecoholic authored and 0x29a committed Dec 30, 2023
commit 0069d2c3163ce3b9e0a35cbf3c17af3836b40268
3 changes: 2 additions & 1 deletion enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -202,7 +202,8 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin):
('Integration and learning platform settings', {
'fields': ('enable_portal_lms_configurations_screen', 'enable_portal_saml_configuration_screen',
'enable_slug_login', 'replace_sensitive_sso_username', 'hide_course_original_price',
'hide_course_price_when_zero', 'enable_generation_of_api_credentials')
'hide_course_price_when_zero', 'allow_enrollment_in_invite_only_courses',
'enable_generation_of_api_credentials')
}),
('Recommended default settings for all enterprise customers', {
'fields': ('site', 'customer_type', 'enable_learner_portal',
1 change: 1 addition & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
@@ -402,6 +402,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",
8 changes: 8 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
@@ -493,6 +493,14 @@ class Meta:
),
)

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):
"""
23 changes: 22 additions & 1 deletion enterprise/utils.py
Original file line number Diff line number Diff line change
@@ -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
@@ -2357,7 +2359,6 @@ def get_md5_hash(content):
Get the MD5 hash digest of the given content.

Arguments:
content (str): Content in string format for calculating MD5 hash digest.

Returns:
(str): MD5 hash digest.
@@ -2396,3 +2397,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,
)
110 changes: 64 additions & 46 deletions enterprise/views.py
Original file line number Diff line number Diff line change
@@ -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):
21 changes: 21 additions & 0 deletions tests/test_enterprise/test_utils.py
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@
from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment
from enterprise.utils import (
enroll_subsidy_users_in_courses,
ensure_course_enrollment_is_allowed,
get_default_invite_key_expiration_date,
get_idiff_list,
get_platform_logo_url,
@@ -546,3 +547,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()
52 changes: 52 additions & 0 deletions tests/test_enterprise/views/test_course_enrollment_view.py
Original file line number Diff line number Diff line change
@@ -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')
1 change: 1 addition & 0 deletions tests/test_utilities.py
Original file line number Diff line number Diff line change
@@ -134,6 +134,7 @@ def setUp(self):
"uuid",
"name",
"slug",
"allow_enrollment_in_invite_only_courses",
"auth_org_id",
"active",
"country",