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

fix: create CEA object when enrolling using a license flow #18

Open
wants to merge 5 commits into
base: 0x29a/bb8626/enroll-enterprise-learners-in-invite-only-courses
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
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
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
29 changes: 29 additions & 0 deletions enterprise/api_client/lms.py
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
11 changes: 3 additions & 8 deletions enterprise/utils.py
Expand Up @@ -2407,19 +2407,14 @@ def truncate_string(string, max_length=MAX_ALLOWED_TEXT_LENGTH):

def ensure_course_enrollment_is_allowed(course_id, email, enrollment_api_client):
"""
Create a CourseEnrollmentAllowed object for invitation-only courses.
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
"""
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(
Copy link
Member Author

@0x29a 0x29a Apr 19, 2024

Choose a reason for hiding this comment

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

I tried to re-use this helper as is, but it was locking a DB row causing enrollment to fail here, because the LMS was trying to modify the locked row. That's why I had to use this API to create CEA in a separate transaction. If this approach is viable, we'll have to backport this endpoint to Palm.

Copy link
Member

Choose a reason for hiding this comment

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

@0x29a It's surprising that we haven't run into this race condition before. Any idea why it might be occuring now?

Irrespective of the reasons, I think using the API is a fine approach to take, given that we get the course details in the previous statement using the API and not an import. It looks like the enrollment_allowed is a recent API addition (added 6/7 months ago). We would have probably used it if it existed earlier, as this allows creating CEA objects from other services like an admin MFE as well.

Copy link
Member Author

@0x29a 0x29a Apr 22, 2024

Choose a reason for hiding this comment

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

Thanks for checking this, @tecoholic.

Any idea why it might be occuring now?

This was bothering me as well, but I was unable to figure this out back then. It turns out that the view modified in openedx#1813 is not atomic. And the view modified in #14 doesn't send any internal HTTP requests in fact, so CEA objects are manipulated within a single transaction. In this PR we modify a view that is both atomic and sends a request to another atomic view, and they both try to modify CEA.

We can also change View with NonAtomicView here, and this will simplify things a bit, but I'm not sure how risky this is.

course_id=course_id,
email=email,
)
enrollment_api_client.allow_enrollment(email, course_id)
9 changes: 9 additions & 0 deletions enterprise/views.py
Expand Up @@ -683,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
2 changes: 1 addition & 1 deletion tests/test_enterprise/api/test_views.py
Expand Up @@ -4512,7 +4512,6 @@ def test_bulk_enrollment_in_bulk_courses_existing_users(
mock_update_or_create_enrollment.return_value = True
mock_get_course_details.return_value.__getitem__.return_value.invitation_only = False


user_one = factories.UserFactory(is_active=True)
user_two = factories.UserFactory(is_active=True)

Expand Down Expand Up @@ -5001,6 +5000,7 @@ def test_bulk_enrollment_invitation_only(
},
]
}

def enroll():
self.client.post(
settings.TEST_SERVER + reverse(
Expand Down
13 changes: 6 additions & 7 deletions tests/test_enterprise/test_utils.py
Expand Up @@ -539,10 +539,9 @@ def test_truncate_string(self):
self.assertEqual(len(truncated_string), MAX_ALLOWED_TEXT_LENGTH)

@ddt.data(True, False)
@mock.patch("enterprise.utils.CourseEnrollmentAllowed")
def test_ensure_course_enrollment_is_allowed(self, invite_only, mock_cea):
def test_ensure_course_enrollment_is_allowed(self, invite_only):
"""
Test that the CourseEnrollmentAllowed is created only for the "invite_only" courses.
Test that the enrollment allow endpoint is called for the "invite_only" courses.
"""
self.create_user()
mock_enrollment_api = mock.Mock()
Expand All @@ -551,9 +550,9 @@ def test_ensure_course_enrollment_is_allowed(self, invite_only, mock_cea):
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
mock_enrollment_api.allow_enrollment.assert_called_with(
self.user.email,
"test-course-id",
)
else:
mock_cea.objects.update_or_create.assert_not_called()
mock_enrollment_api.allow_enrollment.assert_not_called()
8 changes: 3 additions & 5 deletions tests/test_enterprise/views/test_course_enrollment_view.py
Expand Up @@ -1623,10 +1623,8 @@ def test_post_course_specific_enrollment_view_premium_mode(
@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,
Expand Down Expand Up @@ -1664,9 +1662,9 @@ def test_post_course_specific_enrollment_view_invite_only_courses(
}
)

mock_cea.objects.update_or_create.assert_called_with(
course_id=course_id,
email=self.user.email
enrollment_api_client_mock.return_value.allow_enrollment.assert_called_with(
self.user.email,
course_id,
)
assert response.status_code == 302

Expand Down
47 changes: 45 additions & 2 deletions tests/test_enterprise/views/test_grant_data_sharing_permissions.py
Expand Up @@ -90,6 +90,30 @@ def _assert_enterprise_linking_messages(self, response, user_is_active=True):
'You will not be able to log back into your account until you have activated it.'
)

def _assert_allow_enrollment_is_called_correctly(
self,
mock_enrollment_api_client,
license_is_present,
course_invite_only,
enrollment_in_invite_only_courses_allowed,
):
"""
Verify that the allow_enrollment endpoint is called only when:
- License is present
- Course is invite only
- Enrollment in invite only courses is allowed
"""
if license_is_present:
if course_invite_only:
if enrollment_in_invite_only_courses_allowed:
mock_enrollment_api_client.return_value.allow_enrollment.assert_called_once()
else:
mock_enrollment_api_client.return_value.allow_enrollment.assert_not_called()
else:
mock_enrollment_api_client.return_value.allow_enrollment.assert_not_called()
else:
mock_enrollment_api_client.return_value.allow_enrollment.assert_not_called()

@mock.patch('enterprise.views.render', side_effect=fake_render)
@mock.patch('enterprise.models.EnterpriseCatalogApiClient')
@mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient')
Expand Down Expand Up @@ -398,12 +422,21 @@ def test_get_course_specific_consent_not_needed(
@mock.patch('enterprise.views.get_best_mode_from_course_key')
@mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient')
@ddt.data(
str(uuid.uuid4()),
'',
(str(uuid.uuid4()), True, True),
(str(uuid.uuid4()), True, False),
(str(uuid.uuid4()), False, True),
(str(uuid.uuid4()), False, False),
('', True, True),
('', True, False),
('', False, True),
('', False, False),
)
@ddt.unpack
def test_get_course_specific_data_sharing_consent_not_enabled(
self,
license_uuid,
course_invite_only,
allow_enrollment_in_invite_only_courses,
course_catalog_api_client_mock,
mock_get_course_mode,
mock_enrollment_api_client,
Expand All @@ -414,6 +447,7 @@ def test_get_course_specific_data_sharing_consent_not_enabled(
enterprise_customer = EnterpriseCustomerFactory(
name='Starfleet Academy',
enable_data_sharing_consent=False,
allow_enrollment_in_invite_only_courses=allow_enrollment_in_invite_only_courses,
)
content_filter = {
'key': [
Expand All @@ -432,6 +466,8 @@ def test_get_course_specific_data_sharing_consent_not_enabled(
course_catalog_api_client_mock.return_value.program_exists.return_value = True
course_catalog_api_client_mock.return_value.get_course_id.return_value = course_id

mock_enrollment_api_client.return_value.get_course_details.return_value = {"invite_only": course_invite_only}

course_mode = 'verified'
mock_get_course_mode.return_value = course_mode
mock_enrollment_api_client.return_value.get_course_enrollment.return_value = {
Expand Down Expand Up @@ -467,6 +503,13 @@ def test_get_course_specific_data_sharing_consent_not_enabled(
else:
assert not mock_enrollment_api_client.return_value.enroll_user_in_course.called

self._assert_allow_enrollment_is_called_correctly(
mock_enrollment_api_client,
bool(license_uuid),
course_invite_only,
allow_enrollment_in_invite_only_courses
)

@mock.patch('enterprise.views.render', side_effect=fake_render)
@mock.patch('enterprise.views.get_best_mode_from_course_key')
@mock.patch('enterprise.models.EnterpriseCatalogApiClient')
Expand Down