Skip to content

Commit

Permalink
feat: create cea for invite only courses before checkout
Browse files Browse the repository at this point in the history
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 <[email protected]>

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
  • Loading branch information
tecoholic authored and 0x29a committed Oct 30, 2023
1 parent 55589a0 commit 0f34ed8
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 47 deletions.
1 change: 1 addition & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
22 changes: 22 additions & 0 deletions enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
110 changes: 64 additions & 46 deletions enterprise/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_enterprise/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
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
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions tests/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def setUp(self):
"uuid",
"name",
"slug",
"allow_enrollment_in_invite_only_courses",
"active",
"country",
"invite_keys",
Expand Down

0 comments on commit 0f34ed8

Please sign in to comment.