diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 2372e99f4a6..88be03eb54a 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -68,6 +68,7 @@ COURSE_ENROLLMENT_CREATED, COURSE_UNENROLLMENT_COMPLETED, ) +from openedx_filters.learning.enrollment import PreEnrollmentFilter import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price from common.djangoapps.student.emails import send_proctoring_requirements_email @@ -1113,6 +1114,10 @@ class AlreadyEnrolledError(CourseEnrollmentException): pass +class EnrollmentNotAllowed(CourseEnrollmentException): + pass + + class CourseEnrollmentManager(models.Manager): """ Custom manager for CourseEnrollment with Table-level filter methods. @@ -1614,6 +1619,13 @@ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=Fal Also emits relevant events for analytics purposes. """ + try: + user, course_key, mode = PreEnrollmentFilter.run( + user=user, course_key=course_key, mode=mode, + ) + except PreEnrollmentFilter.PreventEnrollment as exc: + raise EnrollmentNotAllowed(str(exc)) from exc + if mode is None: mode = _default_course_mode(str(course_key)) # All the server-side checks for whether a user is allowed to enroll. diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py new file mode 100644 index 00000000000..d36fbb88271 --- /dev/null +++ b/common/djangoapps/student/tests/test_filters.py @@ -0,0 +1,93 @@ +""" +Test that various filters are fired for models in the student app. +""" +from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed +from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from django.test import TestCase, override_settings +from openedx_filters.learning.enrollment import PreEnrollmentFilter + + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from openedx_filters import PipelineStep + + +class TestEnrollmentPipelineStep(PipelineStep): + """ + Utility function used when getting steps for pipeline. + """ + + def run(self, user, course_key, mode): + """Pipeline steps that changes mode to honor.""" + if mode == "no-id-professional": + raise PreEnrollmentFilter.PreventEnrollment() + return {"mode": "honor"} + + +@skip_unless_lms +class EnrollmentFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the enrollment process through the enroll method. + + This class guarantees that the following filters are triggered during the user's enrollment: + + - PreEnrollmentFilter + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create( + username="test", + email="test@example.com", + password="password", + ) + self.user_profile = UserProfileFactory.create(user=self.user, name="Test Example") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.course.enrollment.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestEnrollmentPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_enrollment_filter_executed(self): + """ + Test whether the student enrollment filter is triggered before the user's + enrollment process. + + Expected result: + - PreEnrollmentFilter is triggered and executes TestEnrollmentPipelineStep. + - The arguments that the receiver gets are the arguments used by the filter + with the enrollment mode changed. + """ + enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='audit') + + self.assertEqual('honor', enrollment.mode) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.course.enrollment.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestEnrollmentPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_enrollment_filter_prevent_enroll(self): + """ + Test prevent the user's enrollment through a pipeline step. + + Expected result: + - PreEnrollmentFilter is triggered and executes TestEnrollmentPipelineStep. + - The user can't enroll. + """ + with self.assertRaises(EnrollmentNotAllowed): + CourseEnrollment.enroll(self.user, self.course.id, mode='no-id-professional') diff --git a/openedx/core/djangoapps/enrollments/data.py b/openedx/core/djangoapps/enrollments/data.py index 9cdfaf0c0a6..142e47b80b2 100644 --- a/openedx/core/djangoapps/enrollments/data.py +++ b/openedx/core/djangoapps/enrollments/data.py @@ -17,6 +17,7 @@ CourseEnrollmentClosedError, CourseEnrollmentExistsError, CourseEnrollmentFullError, + CourseEnrollmentNotAllowedError, InvalidEnrollmentAttribute, UserNotFoundError ) @@ -28,7 +29,8 @@ CourseEnrollmentAttribute, CourseFullError, EnrollmentClosedError, - NonExistentCourseError + NonExistentCourseError, + EnrollmentNotAllowed ) from common.djangoapps.student.roles import RoleCache @@ -162,6 +164,8 @@ def create_course_enrollment(username, course_id, mode, is_active): except AlreadyEnrolledError as err: enrollment = get_course_enrollment(username, course_id) raise CourseEnrollmentExistsError(str(err), enrollment) # lint-amnesty, pylint: disable=raise-missing-from + except EnrollmentNotAllowed as err: + raise CourseEnrollmentNotAllowedError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from def update_course_enrollment(username, course_id, mode=None, is_active=None): diff --git a/openedx/core/djangoapps/enrollments/errors.py b/openedx/core/djangoapps/enrollments/errors.py index d75ec943529..a2308308ecb 100644 --- a/openedx/core/djangoapps/enrollments/errors.py +++ b/openedx/core/djangoapps/enrollments/errors.py @@ -25,6 +25,10 @@ class CourseEnrollmentFullError(CourseEnrollmentError): pass +class CourseEnrollmentNotAllowedError(CourseEnrollmentError): + pass + + class CourseEnrollmentExistsError(CourseEnrollmentError): # lint-amnesty, pylint: disable=missing-class-docstring enrollment = None diff --git a/requirements/edunext/base.in b/requirements/edunext/base.in index 7237ff00d7e..79990f80d51 100644 --- a/requirements/edunext/base.in +++ b/requirements/edunext/base.in @@ -37,6 +37,7 @@ eox-theming # Edunext theming plugin. This plugin allows # Libraries # ################### openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) +openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) ##################### # eduNEXT Xblocks # diff --git a/requirements/edunext/base.txt b/requirements/edunext/base.txt index ad431d96720..c7b60d429d5 100644 --- a/requirements/edunext/base.txt +++ b/requirements/edunext/base.txt @@ -24,7 +24,7 @@ django-oauth-toolkit==1.3.2 # via -c requirements/edunext/../edx/base.txt, eox- django-oauth2-provider==0.2.6.1 # via eox-core django-waffle==2.1.0 # via -c requirements/edunext/../edx/base.txt, edx-django-utils, edx-drf-extensions, edx-proctoring, eox-core django-webpack-loader==0.7.0 # via -c requirements/edunext/../edx/base.txt, edx-proctoring -django==2.2.20 # via -c requirements/edunext/../edx/base.txt, django-crum, django-filter, django-model-utils, django-oauth-toolkit, djangorestframework, drf-jwt, drf-yasg, edx-api-doc-tools, edx-django-utils, edx-drf-extensions, edx-opaque-keys, edx-proctoring, edx-when, eox-audit-model, eox-hooks, event-tracking, jsonfield2, openedx-events, rest-condition +django==2.2.20 # via -c requirements/edunext/../edx/base.txt, django-crum, django-filter, django-model-utils, django-oauth-toolkit, djangorestframework, drf-jwt, drf-yasg, edx-api-doc-tools, edx-django-utils, edx-drf-extensions, edx-opaque-keys, edx-proctoring, edx-when, eox-audit-model, eox-hooks, event-tracking, jsonfield2, openedx-events, openedx-filters, rest-condition djangorestframework==3.12.4 # via -c requirements/edunext/../edx/base.txt, drf-jwt, drf-yasg, edx-api-doc-tools, edx-drf-extensions, edx-proctoring, eox-core, eox-hooks, rest-condition drf-jwt==1.19.0 # via -c requirements/edunext/../edx/base.txt, edx-drf-extensions drf-yasg==1.20.0 # via -c requirements/edunext/../edx/base.txt, edx-api-doc-tools @@ -40,7 +40,7 @@ eox-audit-model==0.6.0 # via eox-core, eox-tagging eox-core[eox-audit,sentry,tpa]==5.0.3 # via -r requirements/edunext/base.in, eox-tagging eox-hooks==2.0.0 # via -r requirements/edunext/base.in eox-tagging==3.0.0 # via -r requirements/edunext/base.in -eox-tenant==5.0.1 # via -r requirements/edunext/base.in +eox-tenant==5.1.0 # via -r requirements/edunext/base.in eox-theming==2.0.0 # via -r requirements/edunext/base.in event-tracking==1.0.4 # via -c requirements/edunext/../edx/base.txt, edx-proctoring fs==2.0.18 # via -c requirements/edunext/../edx/base.txt, xblock @@ -56,6 +56,7 @@ markupsafe==1.1.1 # via -c requirements/edunext/../edx/base.txt, jinja2, newrelic==6.2.0.156 # via -c requirements/edunext/../edx/base.txt, edx-django-utils oauthlib==3.0.1 # via -c requirements/edunext/../edx/base.txt, django-oauth-toolkit, requests-oauthlib, social-auth-core openedx-events==0.6.0 # via -r requirements/edunext/base.in, eox-hooks +openedx-filters==0.3.0 # via -r requirements/edunext/base.in packaging==20.9 # via -c requirements/edunext/../edx/base.txt, drf-yasg pbr==5.5.1 # via -c requirements/edunext/../edx/base.txt, stevedore psutil==5.8.0 # via -c requirements/edunext/../edx/base.txt, edx-django-utils @@ -74,7 +75,7 @@ pyyaml==5.4.1 # via -c requirements/edunext/../edx/base.txt, xblock requests-oauthlib==1.3.0 # via -c requirements/edunext/../edx/base.txt, social-auth-core requests==2.25.1 # via -c requirements/edunext/../edx/base.txt, coreapi, django-oauth-toolkit, edx-drf-extensions, edx-rest-api-client, pyjwkest, requests-oauthlib, slumber, social-auth-core rest-condition==1.0.3 # via -c requirements/edunext/../edx/base.txt, edx-drf-extensions -rsa==4.7.2 # via python-jose +rsa==4.8 # via python-jose ruamel.yaml.clib==0.2.2 # via -c requirements/edunext/../edx/base.txt, ruamel.yaml ruamel.yaml==0.17.4 # via -c requirements/edunext/../edx/base.txt, drf-yasg rules==2.2 # via -c requirements/edunext/../edx/base.txt, edx-proctoring