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: create cea for invite only courses before checkout #1813

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [master]
pull_request:
branches: [master]

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mysql8-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
pip uninstall -y mysqlclient
pip install --no-binary mysqlclient mysqlclient
pip uninstall -y xmlsec
pip install --no-binary xmlsec xmlsec
pip install --no-binary xmlsec xmlsec==1.3.13
pip install backports.zoneinfo
- name: Initiate Services
run: |
Expand Down
2 changes: 1 addition & 1 deletion enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ 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',
'enable_generation_of_api_credentials')
'enable_generation_of_api_credentials', 'allow_enrollment_in_invite_only_courses')
}),
('Recommended default settings for all enterprise customers', {
'fields': ('site', 'customer_type', 'enable_learner_portal',
Expand Down
1 change: 1 addition & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ class Meta:
"enable_audit_data_reporting",
"replace_sensitive_sso_username",
"hide_course_original_price",
"allow_enrollment_in_invite_only_courses",
"enable_portal_code_management_screen",
"enable_portal_subscription_management_screen",
"enable_learner_portal",
Expand Down
4 changes: 4 additions & 0 deletions enterprise/api/v1/views/enterprise_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from enterprise.utils import (
enroll_subsidy_users_in_courses,
get_best_mode_from_course_key,
get_course_details_from_course_keys,
track_enrollment,
validate_email_to_link,
)
Expand Down Expand Up @@ -241,6 +242,8 @@ def enroll_learners_in_courses(self, request, pk):
for course_run in course_runs_modes:
course_runs_modes[course_run] = get_best_mode_from_course_key(course_run)

course_details = get_course_details_from_course_keys(course_runs_modes.keys())

emails = set()

for info in enrollments_info:
Expand All @@ -254,6 +257,7 @@ def enroll_learners_in_courses(self, request, pk):
else:
emails.add(info['email'])
info['course_mode'] = course_runs_modes[info['course_run_key']]
info['invitation_only'] = course_details[info['course_run_key']].invitation_only

for email in emails:
try:
Expand Down
29 changes: 29 additions & 0 deletions enterprise/api_client/lms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import time
from urllib.parse import urljoin

import requests
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import ( # pylint: disable=redefined-builtin
ConnectionError,
Expand Down Expand Up @@ -274,6 +275,34 @@ def get_enrolled_courses(self, username):
response.raise_for_status()
return response.json()

def allow_enrollment(self, email, course_id, auto_enroll=False):
"""
Call the enrollment API to allow enrollment for the given email and course_id.

Args:
email (str): The email address of the user to be allowed to enroll in the course.
course_id (str): The string value of the course's unique identifier.
auto_enroll (bool): Whether to auto-enroll the user in the course upon registration / activation.

Returns:
dict: A dictionary containing details of the created CourseEnrollmentAllowed object.

"""
api_url = self.get_api_url("enrollment_allowed")
response = self.client.post(
f"{api_url}/",
json={
'email': email,
'course_id': course_id,
'auto_enroll': auto_enroll,
}
)
if response.status_code == requests.codes.conflict:
LOGGER.info(response.json()["message"])
else:
response.raise_for_status()
return response.json()


class CourseApiClient(NoAuthAPIClient):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2023-12-08 09:54

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0197_auto_20231130_2239'),
]

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 @@ -484,6 +484,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):
"""
Expand Down
38 changes: 37 additions & 1 deletion enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@
CourseUserGroup = None
CourseEnrollmentError = None

try:
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
except ImportError:
CourseOverview = None

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 @@ -2026,6 +2033,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
user_id = subsidy_user_info.get('user_id')
user_email = subsidy_user_info['email'].strip().lower() if 'email' in subsidy_user_info else None
course_mode = subsidy_user_info.get('course_mode')
invitation_only = subsidy_user_info.get('invitation_only')
course_run_key = subsidy_user_info.get('course_run_key')
license_uuid = subsidy_user_info.get('license_uuid')
transaction_id = subsidy_user_info.get('transaction_id')
Expand All @@ -2052,6 +2060,12 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
enrollment_source = enterprise_enrollment_source_model().get_source(
enterprise_enrollment_source_model().CUSTOMER_ADMIN
)
if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses:
CourseEnrollmentAllowed.objects.update_or_create(
course_id=course_run_key,
email=user.email,
)

succeeded, created, source_uuid = customer_admin_enroll_user_with_status(
enterprise_customer,
user,
Expand Down Expand Up @@ -2289,6 +2303,14 @@ def get_best_mode_from_course_key(course_key):
return CourseModes.AUDIT


def get_course_details_from_course_keys(course_keys):
"""
Helper to get a mapping of course keys to course details.
"""
course_overviews = CourseOverview.objects.filter(id__in=course_keys)
return {str(course_overview.id): course_overview for course_overview in course_overviews}


def parse_lms_api_datetime(datetime_string, datetime_format=LMS_API_DATETIME_FORMAT):
"""
Parse a received datetime into a timezone-aware, Python datetime object.
Expand Down Expand Up @@ -2352,7 +2374,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.
Expand Down Expand Up @@ -2382,3 +2403,18 @@ def truncate_string(string, max_length=MAX_ALLOWED_TEXT_LENGTH):
was_truncated = True
return (truncated_string, was_truncated)
return (string, was_truncated)


def ensure_course_enrollment_is_allowed(course_id, email, enrollment_api_client):
"""
Calls the enrollment API to 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
Copy link
Member

@Agrendalath Agrendalath Aug 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit: wouldn't it be better to move the typing directly to the arguments (line 2313)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath Probably. But the file doesn't use inline types anywhere and instead uses types in docstrings. So I decided to just follow the convention.

Copy link
Member

@Agrendalath Agrendalath Aug 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tecoholic, typing (in its current form) is a relatively recent feature in Python, so most people tend to forget about it. While following the convention is important, we should also be open to introducing apparent improvements. We are already gradually adding the typing to more and more repositories in Open edX. Examples: openedx/edx-platform#32591, openedx/opaque-keys#256.

This is not a blocker here, but something to be aware of.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath Alright, in that case, let this PR be the one to bring in the change. I have added the typing for the utility function introduced in this PR.

"""
course_details = enrollment_api_client.get_course_details(course_id)
if course_details["invite_only"]:
enrollment_api_client.allow_enrollment(email, course_id)
119 changes: 73 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 @@ -682,6 +683,15 @@ def _enroll_learner_in_course(
existing_enrollment.get('mode') == constants.CourseModes.AUDIT or
existing_enrollment.get('is_active') is False
):
if enterprise_customer.allow_enrollment_in_invite_only_courses:
ensure_course_enrollment_is_allowed(course_id, request.user.email, enrollment_api_client)
LOGGER.info(
'User {user} is allowed to enroll in Course {course_id}.'.format(
user=request.user.username,
course_id=course_id
)
)

course_mode = get_best_mode_from_course_key(course_id)
LOGGER.info(
'Retrieved Course Mode: {course_modes} for Course {course_id}'.format(
Expand Down Expand Up @@ -1677,12 +1687,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 @@ -1727,51 +1742,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 @@ -1786,6 +1762,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
Loading