Skip to content

Commit

Permalink
feat: enable enrolling in invite_only courses via manage learners UI
Browse files Browse the repository at this point in the history
When enrollment of users to courses marked as "Invite Only" is attempted
from the "Manage Learners" page in the Admin, it results in an error due
to EnrollmentClosedError. This commit, adds handles that scenario by
setting "force_enrollment" to True and creating a CourseEnrollmentAllowed
object if the users with the specified emails didn't exist.
  • Loading branch information
tecoholic authored May 30, 2023
1 parent 1be2ff9 commit 8c499ec
Show file tree
Hide file tree
Showing 15 changed files with 253 additions and 60 deletions.
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]

jobs:
run_tests:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Unreleased
----------
* nothing

[3.42.7]
--------
feat: allow enrollment to invite-only courses via manage learners admin page

[3.42.6]
--------
feat: allow enrollment api admin to see all enrollments
Expand Down
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.6"
__version__ = "3.42.7"

default_app_config = "enterprise.apps.EnterpriseConfig"
6 changes: 6 additions & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ class ManageLearnersForm(forms.Form):
label=_("Enroll these learners in this course"), required=False,
help_text=_("To enroll learners in a course, enter a course ID."),
)
force_enrollment = forms.BooleanField(
label=_("Force Enrollment"),
help_text=_("The selected course is 'Invite Only'. Only staff can enroll learners to this course."),
required=False,
)
course_mode = forms.ChoiceField(
label=_("Course enrollment track"), required=False,
choices=BLANK_CHOICE_DASH + [
Expand Down Expand Up @@ -128,6 +133,7 @@ class Fields:
REASON = "reason"
SALES_FORCE_ID = "sales_force_id"
DISCOUNT = "discount"
FORCE_ENROLLMENT = "force_enrollment"

class CsvColumns:
"""
Expand Down
12 changes: 9 additions & 3 deletions enterprise/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,8 @@ def _enroll_users(
notify=True,
enrollment_reason=None,
sales_force_id=None,
discount=0.0
discount=0.0,
force_enrollment=False,
):
"""
Enroll the users with the given email addresses to the course.
Expand All @@ -654,6 +655,7 @@ def _enroll_users(
mode: The enrollment mode the users will be enrolled in the course with
course_id: The ID of the course in which we want to enroll
notify: Whether to notify (by email) the users that have been enrolled
force_enrollment: Force enrollment into "Invite Only" courses
"""
pending_messages = []
paid_modes = ['verified', 'professional']
Expand All @@ -667,6 +669,7 @@ def _enroll_users(
enrollment_reason=enrollment_reason,
discount=discount,
sales_force_id=sales_force_id,
force_enrollment=force_enrollment,
)
all_successes = succeeded + pending
if notify:
Expand Down Expand Up @@ -783,6 +786,7 @@ def post(self, request, customer_uuid):
sales_force_id = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.SALES_FORCE_ID)
course_mode = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE_MODE)
course_id = None
force_enrollment = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.FORCE_ENROLLMENT)

if not course_id_with_emails:
course_details = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE) or {}
Expand All @@ -797,7 +801,8 @@ def post(self, request, customer_uuid):
notify=notify,
enrollment_reason=manual_enrollment_reason,
sales_force_id=sales_force_id,
discount=discount
discount=discount,
force_enrollment=force_enrollment,
)
else:
for course_id, emails in course_id_with_emails.items():
Expand All @@ -812,7 +817,8 @@ def post(self, request, customer_uuid):
notify=notify,
enrollment_reason=manual_enrollment_reason,
sales_force_id=sales_force_id,
discount=discount
discount=discount,
force_enrollment=force_enrollment,
)

# Redirect to GET if everything went smooth.
Expand Down
13 changes: 11 additions & 2 deletions enterprise/api_client/lms.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,15 @@ def has_course_mode(self, course_run_id, mode):
return any(course_mode for course_mode in course_modes if course_mode['slug'] == mode)

@JwtLmsApiClient.refresh_token
def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterprise_uuid=None):
def enroll_user_in_course(
self,
username,
course_id,
mode,
cohort=None,
enterprise_uuid=None,
force_enrollment=False,
):
"""
Call the enrollment API to enroll the user in the course specified by course_id.
Expand All @@ -252,7 +260,8 @@ def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterpri
'is_active': True,
'mode': mode,
'cohort': cohort,
'enterprise_uuid': str(enterprise_uuid)
'enterprise_uuid': str(enterprise_uuid),
'force_enrollment': force_enrollment,
}
)

Expand Down
17 changes: 16 additions & 1 deletion enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@
)

try:
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed
except ImportError:
CourseEnrollment = None
CourseEnrollmentAllowed = None

try:
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
Expand Down Expand Up @@ -647,7 +648,21 @@ def enroll_user_pending_registration_with_status(self, email, course_mode, *cour
license_uuid = None

new_enrollments = {}
enrollment_api_client = EnrollmentApiClient()

for course_id in course_ids:
# Check if the course is "Invite Only" and add CEA if it is.
course_details = enrollment_api_client.get_course_details(course_id)

if course_details["invite_only"]:
if not CourseEnrollmentAllowed:
raise NotConnectedToOpenEdX()

CourseEnrollmentAllowed.objects.update_or_create(
email=email,
course_id=course_id
)

__, created = PendingEnrollment.objects.update_or_create(
user=pending_ecu,
course_id=course_id,
Expand Down
36 changes: 34 additions & 2 deletions enterprise/static/enterprise/js/manage_learners.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function makeOption(name, value) {
return $("<option></option>").text(name).val(value);
}

function fillModeDropdown(data) {
function updateCourseData(data) {
/*
Given a set of data fetched from the enrollment API, populate the Course Mode
dropdown with those options that are valid for the course entered in the
Expand All @@ -19,6 +19,11 @@ function fillModeDropdown(data) {
var previous_value = $course_mode.val();
applyModes(data.course_modes);
$course_mode.val(previous_value);

// If the course is invite-only, show the force enrollment box.
if (data.invite_only) {
$("#id_force_enrollment").parent().show();
}
}

function applyModes(modes) {
Expand All @@ -43,7 +48,7 @@ function loadCourseModes(success, failure) {
return;
}
$.ajax({method: 'get', url: enrollmentApiRoot + "course/" + courseId})
.done(success || fillModeDropdown)
.done(success || updateCourseData)
.fail(failure || function (err, jxHR, errstat) { disableMode(disableReason); });
});
}
Expand Down Expand Up @@ -134,11 +139,38 @@ function loadPage() {
programEnrollment.$control.oldValue = null;
});

// NOTE: As the course details won't be fetched for course id in the CSV
// file, this has a potential side-effect of enrolling learners into the courses
// which might be marked as closed for reasons other then being "Invite Only".
//
// This is considered as a reasonable tradeoff at the time of this addition.
// Currently, the EnrollmentListView does not support invitation only courses.
// This problem does not happen in the Instructor Dashboard because it doesn't
// invoke access checks when calling the enroll method. Modifying the enroll method
// is a high-risk change, and it seems that the API will need some changes in
// the near future anyway - when the Instructor Dashboard is converted into an
// MFE (it could be an excellent opportunity to eliminate many legacy behaviors
// there, too).
$("#id_bulk_upload_csv").change(function(e) {
if (e.target.value) {
var force_enrollment = $("#id_force_enrollment");
force_enrollment.parent().show();
force_enrollment.siblings(".helptext")[0].innerHTML = gettext(
"If any of the courses in the CSV file are marked 'Invite Only', " +
"this should be enabled for the enrollments to go through in those courses."
);
}
});

if (courseEnrollment.$control.val()) {
courseEnrollment.$control.trigger("input");
} else if (programEnrollment.$control.val()) {
programEnrollment.$control.trigger("input");
}

// hide the force_invite_only checkbox by default
$("#id_force_enrollment").parent().hide();

$("#learner-management-form").submit(addCheckedLearnersToEnrollBox);
}

Expand Down
12 changes: 9 additions & 3 deletions enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1658,12 +1658,15 @@ def enroll_user(enterprise_customer, user, course_mode, *course_ids, **kwargs):
user: The user model object who needs to be enrolled in the course
course_mode: The string representation of the mode with which the enrollment should be created
*course_ids: An iterable containing any number of course IDs to eventually enroll the user in.
kwargs: Should contain enrollment_client if it's already been instantiated and should be passed in.
kwargs: Contains optional params such as:
- enrollment_client, if it's already been instantiated and should be passed in
- force_enrollment, if the course is "Invite Only" and the "force_enrollment" is needed
Returns:
Boolean: Whether or not enrollment succeeded for all courses specified
"""
enrollment_client = kwargs.pop('enrollment_client', None)
force_enrollment = kwargs.pop('force_enrollment', False)
if not enrollment_client:
from enterprise.api_client.lms import EnrollmentApiClient # pylint: disable=import-outside-toplevel
enrollment_client = EnrollmentApiClient()
Expand All @@ -1678,7 +1681,8 @@ def enroll_user(enterprise_customer, user, course_mode, *course_ids, **kwargs):
user.username,
course_id,
course_mode,
enterprise_uuid=str(enterprise_customer_user.enterprise_customer.uuid)
enterprise_uuid=str(enterprise_customer_user.enterprise_customer.uuid),
force_enrollment=force_enrollment,
)
except HttpClientError as exc:
# Check if user is already enrolled then we should ignore exception
Expand Down Expand Up @@ -1956,6 +1960,7 @@ def enroll_users_in_course(
enrollment_reason=None,
discount=0.0,
sales_force_id=None,
force_enrollment=False,
):
"""
Enroll existing users in a course, and create a pending enrollment for nonexisting users.
Expand All @@ -1969,6 +1974,7 @@ def enroll_users_in_course(
enrollment_reason (str): A reason for enrollment.
discount (Decimal): Percentage discount for enrollment.
sales_force_id (str): Salesforce opportunity id.
force_enrollment (bool): Force enrollment into 'Invite Only' courses.
Returns:
successes: A list of users who were successfully enrolled in the course.
Expand All @@ -1985,7 +1991,7 @@ def enroll_users_in_course(
failures = []

for user in existing_users:
succeeded = enroll_user(enterprise_customer, user, course_mode, course_id)
succeeded = enroll_user(enterprise_customer, user, course_mode, course_id, force_enrollment=force_enrollment)
if succeeded:
successes.append(user)
if enrollment_requester and enrollment_reason:
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==2021.10.8
# via requests
charset-normalizer==2.0.11
# via requests
codecov==2.1.12
codecov==2.1.13
# via -r requirements/ci.in
coverage==6.3.1
# via codecov
Expand Down
2 changes: 1 addition & 1 deletion test_utils/fake_enrollment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def get_course_details(course_id):
return None


def enroll_user_in_course(user, course_id, mode, cohort=None, enterprise_uuid=None):
def enroll_user_in_course(user, course_id, mode, cohort=None, enterprise_uuid=None, force_enrollment=False): # pylint: disable=unused-argument
"""
Fake implementation.
"""
Expand Down
Loading

0 comments on commit 8c499ec

Please sign in to comment.