Skip to content

Commit

Permalink
feat: allow enrollment into 'invite-only' courses via enrollment url
Browse files Browse the repository at this point in the history
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
  • Loading branch information
tecoholic authored Aug 3, 2023
1 parent bbafc5e commit 65cee2c
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 47 deletions.
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
Your project description goes here.
"""

__version__ = "3.42.9"
__version__ = "3.42.10"

default_app_config = "enterprise.apps.EnterpriseConfig"
1 change: 1 addition & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions enterprise/migrations/0156_auto_20230724_1611.py
Original file line number Diff line number Diff line change
@@ -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."),
),
]
8 changes: 8 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
21 changes: 21 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 @@ -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,
)
110 changes: 64 additions & 46 deletions enterprise/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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):
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
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,
Expand Down Expand Up @@ -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()
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 @@ -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')
Expand Down
1 change: 1 addition & 0 deletions tests/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def setUp(self):
"reply_to",
"hide_labor_market_data",
"hide_course_price_when_zero",
"allow_enrollment_in_invite_only_courses",
]
),
(
Expand Down

0 comments on commit 65cee2c

Please sign in to comment.