Skip to content

Commit

Permalink
feat: allow enrollment into 'invite-only' courses
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 committed Jul 24, 2023
1 parent bbafc5e commit 9f4b479
Show file tree
Hide file tree
Showing 9 changed files with 4,912 additions and 3,240 deletions.
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):
"""
Creates a CourseEnrollmentAllowed object for initiation 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,
)
109 changes: 63 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,56 @@ def post(self, request, enterprise_uuid, course_id):

return redirect(premium_flow)

def _handle_user_consent_flow(self, 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
Loading

0 comments on commit 9f4b479

Please sign in to comment.