Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions openedx/core/djangoapps/enrollments/tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""
Test for CourseEnrollmentViewStarted filter in enrollment views.
"""
import json

from django.test import override_settings
from django.urls import reverse
from openedx_filters import PipelineStep
from openedx_filters.learning.filters import CourseEnrollmentViewStarted
from rest_framework import status
from rest_framework.test import APITestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory

from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.

PATH = "openedx.core.djangoapps.enrollments.tests.test_filters.TestCourseEnrollmentViewStartedPipelineStep"


class TestCourseEnrollmentViewStartedPipelineStep(PipelineStep):
"""
Utility pipeline step that prevents enrollment via CourseEnrollmentViewStarted filter.
"""

def run_filter(self, user, course_key, requester_is_backend_service): # pylint: disable=arguments-differ
"""Pipeline step that prevents enrollment for specific users or conditions."""
if user.username == "blocked_user":
raise CourseEnrollmentViewStarted.PreventEnrollment(
"User is not allowed to enroll in this course."
)
# Set a side effect on the user to verify the filter was executed
user.profile.set_meta({"enrollment_filter_executed": True})
user.profile.save()
return {
"user": user,
"course_key": course_key,
"requester_is_backend_service": requester_is_backend_service,
}


@skip_unless_lms
class CourseEnrollmentViewStartedFilterTest(APITestCase, ModuleStoreTestCase):
"""
Tests for the CourseEnrollmentViewStarted filter in the enrollment view.

This class guarantees that the following filters are triggered when attempting
to enroll a user through the enrollment API endpoint:

- CourseEnrollmentViewStarted
"""

def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.course = CourseFactory.create(emit_signals=True)
self.user = UserFactory.create(
username="test_user",
email="test@example.com",
password="password",
)
self.blocked_user = UserFactory.create(
username="blocked_user",
email="blocked@example.com",
password="password",
)
UserProfileFactory.create(user=self.user, name="Test User")
UserProfileFactory.create(user=self.blocked_user, name="Blocked User")

# Create course modes
Comment thread
Copilot marked this conversation as resolved.
CourseModeFactory.create(
Comment thread
Copilot marked this conversation as resolved.
course_id=self.course.id,
mode_slug='audit',
mode_display_name='Audit'
)

@override_settings(
OPEN_EDX_FILTERS_CONFIG={
"org.openedx.learning.course.enrollment.view.started.v1": {
"pipeline": [
PATH,
],
"fail_silently": False,
},
},
)
def test_course_enrollment_view_started_filter_executed(self):
"""
Test that the CourseEnrollmentViewStarted filter is executed when enrolling through the view.

Expected result:
- CourseEnrollmentViewStarted is triggered and executes the pipeline step.
- The enrollment is created successfully if the filter allows it.
- The filter pipeline step sets a side effect on the user's profile.
"""
self.client.login(username=self.user.username, password="password")

response = self.client.post(
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

self.assertEqual(status.HTTP_200_OK, response.status_code)
# Verify the filter was executed by checking the side effect
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.get_meta("enrollment_filter_executed"))

Comment thread
kiram15 marked this conversation as resolved.
@override_settings(
OPEN_EDX_FILTERS_CONFIG={
"org.openedx.learning.course.enrollment.view.started.v1": {
"pipeline": [
PATH,
],
"fail_silently": False,
},
},
)
def test_course_enrollment_view_started_filter_prevent_enrollment(self):
"""
Test that enrollment is prevented when filter raises PreventEnrollment.

Expected result:
- CourseEnrollmentViewStarted is triggered and executes the pipeline step.
- The pipeline step raises PreventEnrollment for blocked_user.
- The endpoint returns HTTP 403 Forbidden.
"""
self.client.login(username=self.blocked_user.username, password="password")

response = self.client.post(
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
response_data = json.loads(response.content.decode('utf-8'))
self.assertIn("message", response_data)
self.assertIn("User is not allowed to enroll", response_data["message"])

@override_settings(OPEN_EDX_FILTERS_CONFIG={})
def test_course_enrollment_view_started_without_filter_configuration(self):
"""
Test enrollment without filter configuration (no filter interference).

Expected result:
- CourseEnrollmentViewStarted does not have any effect.
- The enrollment process succeeds without filter intervention.
"""
self.client.login(username=self.user.username, password="password")

response = self.client.post(
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

self.assertEqual(status.HTTP_200_OK, response.status_code)
39 changes: 1 addition & 38 deletions openedx/core/djangoapps/enrollments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from urllib.parse import quote

import ddt
import httpretty
import pytest
import pytz
from django.conf import settings
Expand Down Expand Up @@ -45,8 +44,6 @@
from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.django_test_client_utils import get_absolute_url
from openedx.features.enterprise_support.tests import FAKE_ENTERPRISE_CUSTOMER
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range

Expand Down Expand Up @@ -159,7 +156,7 @@ def _get_enrollments(self):
@override_waffle_flag(ENABLE_NOTIFICATIONS, True)
@ddt.ddt
@skip_unless_lms
class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, EnterpriseServiceMockMixin):
class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
"""
Test user enrollment, especially with different course modes.
"""
Expand Down Expand Up @@ -1243,40 +1240,6 @@ def test_enrollment_with_global_staff_permissions(self, using_global_staff_user,
assert course_mode == CourseMode.VERIFIED
self.client.logout()

@httpretty.activate
@override_settings(ENTERPRISE_SERVICE_WORKER_USERNAME='enterprise_worker',
FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True))
@patch('openedx.features.enterprise_support.api.enterprise_customer_from_api')
def test_enterprise_course_enrollment_with_ec_uuid(self, mock_enterprise_customer_from_api):
"""Verify that the enrollment completes when the EnterpriseCourseEnrollment creation succeeds. """
UserFactory.create(
username='enterprise_worker',
email=self.EMAIL,
password=self.PASSWORD,
)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=CourseMode.DEFAULT_MODE_SLUG,
mode_display_name=CourseMode.DEFAULT_MODE_SLUG,
)
consent_kwargs = {
'username': self.user.username,
'course_id': str(self.course.id),
'ec_uuid': 'this-is-a-real-uuid'
}
mock_enterprise_customer_from_api.return_value = FAKE_ENTERPRISE_CUSTOMER
self.mock_enterprise_course_enrollment_post_api()
self.mock_consent_missing(**consent_kwargs)
self.mock_consent_post(**consent_kwargs)
self.assert_enrollment_status(
expected_status=status.HTTP_200_OK,
as_server=True,
username='enterprise_worker',
linked_enterprise_customer='this-is-a-real-uuid',
)
assert httpretty.last_request().path == '/consent/api/v1/data_sharing_consent' # pylint: disable=no-member
assert httpretty.last_request().method == httpretty.POST

def test_enrollment_attributes_always_written(self):
""" Enrollment attributes should always be written, regardless of whether
the enrollment is being created or updated.
Comment thread
kiram15 marked this conversation as resolved.
Expand Down
47 changes: 17 additions & 30 deletions openedx/core/djangoapps/enrollments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
from django.db import IntegrityError # lint-amnesty, pylint: disable=wrong-import-order
from django.db.models import Q # lint-amnesty, pylint: disable=wrong-import-order
from django.utils.decorators import method_decorator # lint-amnesty, pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.jwt.authentication import (
from edx_rest_framework_extensions.auth.jwt.authentication import ( # lint-amnesty, pylint: disable=wrong-import-order
JwtAuthentication,
) # lint-amnesty, pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.session.authentication import (
)
from edx_rest_framework_extensions.auth.session.authentication import ( # lint-amnesty, pylint: disable=wrong-import-order
SessionAuthenticationAllowInactiveUser,
) # lint-amnesty, pylint: disable=wrong-import-order
)
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order
from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -57,12 +57,7 @@
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.exceptions import CourseNotFoundError
from openedx.core.lib.log_utils import audit_log
from openedx.features.enterprise_support.api import (
ConsentApiServiceClient,
EnterpriseApiException,
EnterpriseApiServiceClient,
enterprise_enabled,
)
from openedx_filters.learning.filters import CourseEnrollmentViewStarted
Comment thread
kiram15 marked this conversation as resolved.

log = logging.getLogger(__name__)
REQUIRED_ATTRIBUTES = {
Expand Down Expand Up @@ -774,26 +769,18 @@ def post(self, request):
data={"message": ("'{value}' is an invalid enrollment activation status.").format(value=is_active)},
)

explicit_linked_enterprise = request.data.get("linked_enterprise_customer")
if explicit_linked_enterprise and has_api_key_permissions and enterprise_enabled():
enterprise_api_client = EnterpriseApiServiceClient()
consent_client = ConsentApiServiceClient()
try:
enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id))
except EnterpriseApiException as error:
log.exception(
"An unexpected error occurred while creating the new EnterpriseCourseEnrollment "
"for user [%s] in course run [%s]",
username,
course_id,
)
raise CourseEnrollmentError(str(error)) # lint-amnesty, pylint: disable=raise-missing-from
kwargs = {
"username": username,
"course_id": str(course_id),
"enterprise_customer_uuid": explicit_linked_enterprise,
}
consent_client.provide_consent(**kwargs)
# Filter hook that allows plugins (e.g., Enterprise) to run enrollment-related logic
# and optionally prevent enrollment via CourseEnrollmentViewStarted.PreventEnrollment.
try:
# .. filter_implemented_name: CourseEnrollmentViewStarted
# .. filter_type: org.openedx.learning.course.enrollment.view.started.v1
user, course_id, has_api_key_permissions = CourseEnrollmentViewStarted.run_filter(
user=user,
course_key=course_id,
requester_is_backend_service=has_api_key_permissions,
)
Comment on lines +777 to +781
Comment on lines +777 to +781
except CourseEnrollmentViewStarted.PreventEnrollment as exc:
raise EnrollmentNotAllowed(str(exc)) from exc
Comment on lines +774 to +783

enrollment_attributes = request.data.get("enrollment_attributes")
force_enrollment = request.data.get("force_enrollment")
Expand Down
2 changes: 1 addition & 1 deletion requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ django-stubs<6
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==8.3.0
edx-enterprise==8.4.0

# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
Expand Down
Loading