From 95bb64c607f7e9250197b616c39284300d16cc48 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 4 Mar 2026 08:47:48 -0800 Subject: [PATCH] feat: remove enterprise enrollment API imports and conditional block from enrollments/views.py ENT-11570 --- .../enrollments/tests/test_filters.py | 168 ++++++++++++++++++ .../enrollments/tests/test_views.py | 39 +--- openedx/core/djangoapps/enrollments/views.py | 67 +++---- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 8 files changed, 201 insertions(+), 83 deletions(-) create mode 100644 openedx/core/djangoapps/enrollments/tests/test_filters.py diff --git a/openedx/core/djangoapps/enrollments/tests/test_filters.py b/openedx/core/djangoapps/enrollments/tests/test_filters.py new file mode 100644 index 000000000000..d956583057c5 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/tests/test_filters.py @@ -0,0 +1,168 @@ +""" +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 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 +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +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 + CourseModeFactory.create( + 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( + reverse('courseenrollments'), + { + "course_details": {"course_id": str(self.course.id)}, + "mode": "audit", + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + # Verify the filter was executed by checking the side effect + self.user.profile.refresh_from_db() + meta = self.user.profile.get_meta() + assert meta.get("enrollment_filter_executed") is True + + @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", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + response_data = json.loads(response.content.decode('utf-8')) + assert "message" in response_data + assert "User is not allowed to enroll" in 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( + reverse('courseenrollments'), + { + "course_details": {"course_id": str(self.course.id)}, + "mode": "audit", + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index 5ca7a0f7a7c2..094fb5b7adf8 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -11,7 +11,6 @@ from zoneinfo import ZoneInfo import ddt -import httpretty import pytest from django.conf import settings from django.core.cache import cache @@ -42,8 +41,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 @@ -155,7 +152,7 @@ def _get_enrollments(self): @override_settings(EDX_API_KEY="i am a key") @ddt.ddt @skip_unless_lms -class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, EnterpriseServiceMockMixin): +class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): """ Test user enrollment, especially with different course modes. """ @@ -1235,40 +1232,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. diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 0781e2e2d3a0..c5fc024a8f31 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -6,26 +6,27 @@ import logging -from django.core.exceptions import ( # pylint: disable=wrong-import-order +from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order ObjectDoesNotExist, ValidationError, ) -from django.db import IntegrityError # pylint: disable=wrong-import-order -from django.db.models import Q # pylint: disable=wrong-import-order -from django.utils.decorators import method_decorator # pylint: disable=wrong-import-order -from edx_rest_framework_extensions.auth.jwt.authentication import ( # pylint: disable=wrong-import-order +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 ( # lint-amnesty, pylint: disable=wrong-import-order JwtAuthentication, ) -from edx_rest_framework_extensions.auth.session.authentication import ( # pylint: disable=wrong-import-order +from edx_rest_framework_extensions.auth.session.authentication import ( # lint-amnesty, pylint: disable=wrong-import-order SessionAuthenticationAllowInactiveUser, ) -from opaque_keys import InvalidKeyError # pylint: disable=wrong-import-order -from opaque_keys.edx.keys import CourseKey # pylint: disable=wrong-import-order -from rest_framework import permissions, status # pylint: disable=wrong-import-order -from rest_framework.generics import ListAPIView # pylint: disable=wrong-import-order -from rest_framework.response import Response # pylint: disable=wrong-import-order -from rest_framework.throttling import UserRateThrottle # pylint: disable=wrong-import-order -from rest_framework.views import APIView # pylint: disable=wrong-import-order +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 openedx_filters.learning.filters import CourseEnrollmentViewStarted +from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.auth import user_has_role @@ -57,12 +58,6 @@ 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, -) log = logging.getLogger(__name__) REQUIRED_ATTRIBUTES = { @@ -774,26 +769,18 @@ def post(self, request): data={"message": ("'{value}' is an invalid enrollment activation status.").format(value=is_active)}, # noqa: UP032 # pylint: disable=line-too-long ) - 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)) # pylint: disable=raise-missing-from # noqa: B904 - 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 + CourseEnrollmentViewStarted.run_filter( + user=user, + course_key=course_id, + requester_is_backend_service=has_api_key_permissions, + ) + except CourseEnrollmentViewStarted.PreventEnrollment as exc: + raise EnrollmentNotAllowed(str(exc)) from exc enrollment_attributes = request.data.get("enrollment_attributes") force_enrollment = request.data.get("force_enrollment") @@ -924,7 +911,7 @@ def post(self, request): log.exception("Missing cohort [%s] in course run [%s]", cohort_name, course_id) return Response( status=status.HTTP_400_BAD_REQUEST, - data={"message": "An error occured while adding to cohort [%s]" % cohort_name}, # noqa: UP031 + data={"message": "An error occurred while adding to cohort [%s]" % cohort_name}, # noqa: UP031 ) finally: # Assumes that the ecommerce service uses an API key to authenticate. diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 2fe35fcd6841..3591b6bf1417 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -44,7 +44,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. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 05f7630f3a51..6f741e4d2e5a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -483,7 +483,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.3.0 +edx-enterprise==8.4.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 0dda45866702..a2329bc60022 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -756,7 +756,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.3.0 +edx-enterprise==8.4.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index b06f157fb76f..3e49055d9bed 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -573,7 +573,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.3.0 +edx-enterprise==8.4.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index a66a29307700..a89b9e823d45 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -589,7 +589,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.3.0 +edx-enterprise==8.4.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt