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..9b1ab500b3a2 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/tests/test_filters.py @@ -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 + +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", + ) + + 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")) + + @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( + reverse('courseenrollments'), + { + "course_details": {"course_id": str(self.course.id)}, + "mode": "audit", + }, + format="json", + ) + + self.assertEqual(status.HTTP_200_OK, response.status_code) diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index a6b34cbfc60b..137c3a600295 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -10,7 +10,6 @@ from urllib.parse import quote import ddt -import httpretty import pytest import pytz from django.conf import settings @@ -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 @@ -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. """ @@ -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. diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index dc3423245e9b..df26ec4723e4 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -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 +) 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 @@ -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 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)}, ) - 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, + ) + 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") diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 1bb69b0fb57f..8e63c78219a1 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -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. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index e1bbf5b75ca2..234f842dfc0e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index bca03a007cba..93a2cf62c067 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 1afd665d300d..eb811cd7eac0 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -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 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 5ac6f2663600..30636f3eb6cd 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -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