feat: add first batch of Open edX Filters
* Add PreEnrollmentFilter * Add PreRegisterFilter * Add PreLoginFilter
This commit is contained in:
@@ -66,6 +66,7 @@ from openedx_events.learning.signals import (
|
||||
COURSE_ENROLLMENT_CREATED,
|
||||
COURSE_UNENROLLMENT_COMPLETED,
|
||||
)
|
||||
from openedx_filters.learning.filters import CourseEnrollmentStarted
|
||||
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
|
||||
@@ -1117,6 +1118,10 @@ class AlreadyEnrolledError(CourseEnrollmentException):
|
||||
pass
|
||||
|
||||
|
||||
class EnrollmentNotAllowed(CourseEnrollmentException):
|
||||
pass
|
||||
|
||||
|
||||
class CourseEnrollmentManager(models.Manager):
|
||||
"""
|
||||
Custom manager for CourseEnrollment with Table-level filter methods.
|
||||
@@ -1627,6 +1632,13 @@ class CourseEnrollment(models.Model):
|
||||
|
||||
Also emits relevant events for analytics purposes.
|
||||
"""
|
||||
try:
|
||||
user, course_key, mode = CourseEnrollmentStarted.run_filter(
|
||||
user=user, course_key=course_key, mode=mode,
|
||||
)
|
||||
except CourseEnrollmentStarted.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.
|
||||
|
||||
104
common/djangoapps/student/tests/test_filters.py
Normal file
104
common/djangoapps/student/tests/test_filters.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Test that various filters are fired for models in the student app.
|
||||
"""
|
||||
from django.test import override_settings
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from openedx_filters.learning.filters import CourseEnrollmentStarted
|
||||
from openedx_filters import PipelineStep
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestEnrollmentPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, user, course_key, mode): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that changes mode to honor."""
|
||||
if mode == "no-id-professional":
|
||||
raise CourseEnrollmentStarted.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:
|
||||
|
||||
- CourseEnrollmentStarted
|
||||
"""
|
||||
|
||||
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:
|
||||
- CourseEnrollmentStarted 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:
|
||||
- CourseEnrollmentStarted 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')
|
||||
|
||||
@override_settings(OPEN_EDX_FILTERS_CONFIG={})
|
||||
def test_enrollment_without_filter_configuration(self):
|
||||
"""
|
||||
Test usual enrollment process, without filter's intervention.
|
||||
|
||||
Expected result:
|
||||
- CourseEnrollmentStarted does not have any effect on the enrollment process.
|
||||
- The enrollment process ends successfully.
|
||||
"""
|
||||
enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='audit')
|
||||
|
||||
self.assertEqual('audit', enrollment.mode)
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
@@ -29,6 +29,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from openedx_events.learning.data import UserData, UserPersonalData
|
||||
from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED
|
||||
from openedx_filters.learning.filters import StudentLoginRequested
|
||||
|
||||
from common.djangoapps import third_party_auth
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
@@ -564,6 +565,13 @@ def login_user(request, api_version='v1'):
|
||||
|
||||
possibly_authenticated_user = user
|
||||
|
||||
try:
|
||||
possibly_authenticated_user = StudentLoginRequested.run_filter(user=possibly_authenticated_user)
|
||||
except StudentLoginRequested.PreventLogin as exc:
|
||||
raise AuthFailedError(
|
||||
str(exc), redirect_url=exc.redirect_to, error_code=exc.error_code, context=exc.context,
|
||||
) from exc
|
||||
|
||||
if not is_user_third_party_authenticated:
|
||||
possibly_authenticated_user = _authenticate_first_party(request, user, third_party_auth_requested)
|
||||
if possibly_authenticated_user and password_policy_compliance.should_enforce_compliance_on_login():
|
||||
|
||||
@@ -25,6 +25,7 @@ from edx_django_utils.monitoring import set_custom_attribute
|
||||
from edx_toggles.toggles import LegacyWaffleFlag, LegacyWaffleFlagNamespace
|
||||
from openedx_events.learning.data import UserData, UserPersonalData
|
||||
from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED
|
||||
from openedx_filters.learning.filters import StudentRegistrationRequested
|
||||
from pytz import UTC
|
||||
from ratelimit.decorators import ratelimit
|
||||
from requests import HTTPError
|
||||
@@ -569,6 +570,14 @@ class RegistrationView(APIView):
|
||||
data = request.POST.copy()
|
||||
self._handle_terms_of_service(data)
|
||||
|
||||
try:
|
||||
data = StudentRegistrationRequested.run_filter(form_data=data)
|
||||
except StudentRegistrationRequested.PreventRegistration as exc:
|
||||
errors = {
|
||||
"error_message": [{"user_message": str(exc)}],
|
||||
}
|
||||
return self._create_response(request, errors, status_code=exc.status_code)
|
||||
|
||||
response = self._handle_duplicate_email_username(request, data)
|
||||
if response:
|
||||
return response
|
||||
|
||||
274
openedx/core/djangoapps/user_authn/views/tests/test_filters.py
Normal file
274
openedx/core/djangoapps/user_authn/views/tests/test_filters.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Test that various filters are fired for the vies in the user_authn app.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from openedx_filters import PipelineStep
|
||||
from openedx_filters.learning.filters import StudentLoginRequested, StudentRegistrationRequested
|
||||
from rest_framework import status
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
|
||||
from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestRegisterPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, form_data): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that changes the user's username."""
|
||||
username = f"{form_data.get('username')}-OpenEdx"
|
||||
form_data["username"] = username
|
||||
return {
|
||||
"form_data": form_data,
|
||||
}
|
||||
|
||||
|
||||
class TestAnotherRegisterPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, form_data): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that changes the user's username."""
|
||||
username = f"{form_data.get('username')}-Test"
|
||||
form_data["username"] = username
|
||||
return {
|
||||
"form_data": form_data,
|
||||
}
|
||||
|
||||
|
||||
class TestStopRegisterPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, form_data): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that stops the user's registration process."""
|
||||
raise StudentRegistrationRequested.PreventRegistration("You can't register on this site.", status_code=403)
|
||||
|
||||
|
||||
class TestLoginPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, user): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that adds a field to the user's profile."""
|
||||
user.profile.set_meta({"logged_in": True})
|
||||
user.profile.save()
|
||||
return {
|
||||
"user": user
|
||||
}
|
||||
|
||||
|
||||
class TestAnotherLoginPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, user): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that adds a field to the user's profile."""
|
||||
new_meta = user.profile.get_meta()
|
||||
new_meta.update({"another_logged_in": True})
|
||||
user.profile.set_meta(new_meta)
|
||||
user.profile.save()
|
||||
return {
|
||||
"user": user
|
||||
}
|
||||
|
||||
|
||||
class TestStopLoginPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility function used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, user): # pylint: disable=arguments-differ
|
||||
"""Pipeline steps that stops the user's login."""
|
||||
raise StudentLoginRequested.PreventLogin("You can't login on this site.")
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class RegistrationFiltersTest(UserAPITestCase):
|
||||
"""
|
||||
Tests for the Open edX Filters associated with the user registration process.
|
||||
|
||||
This class guarantees that the following filters are triggered during the user's registration:
|
||||
|
||||
- StudentRegistrationRequested
|
||||
"""
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super().setUp()
|
||||
self.url = reverse("user_api_registration")
|
||||
self.user_info = {
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"username": "test",
|
||||
"password": "password",
|
||||
"honor_code": "true",
|
||||
}
|
||||
|
||||
@override_settings(
|
||||
OPEN_EDX_FILTERS_CONFIG={
|
||||
"org.openedx.learning.student.registration.requested.v1": {
|
||||
"pipeline": [
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestRegisterPipelineStep",
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestAnotherRegisterPipelineStep",
|
||||
],
|
||||
"fail_silently": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_register_filter_executed(self):
|
||||
"""
|
||||
Test whether the student register filter is triggered before the user's
|
||||
registration process.
|
||||
|
||||
Expected result:
|
||||
- StudentRegistrationRequested is triggered and executes TestRegisterPipelineStep.
|
||||
- The user's username is updated.
|
||||
"""
|
||||
self.client.post(self.url, self.user_info)
|
||||
|
||||
user = User.objects.filter(username=f"{self.user_info.get('username')}-OpenEdx-Test")
|
||||
self.assertTrue(user)
|
||||
|
||||
@override_settings(
|
||||
OPEN_EDX_FILTERS_CONFIG={
|
||||
"org.openedx.learning.student.registration.requested.v1": {
|
||||
"pipeline": [
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestRegisterPipelineStep",
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestStopRegisterPipelineStep",
|
||||
],
|
||||
"fail_silently": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_register_filter_prevent_registration(self):
|
||||
"""
|
||||
Test prevent the user's registration through a pipeline step.
|
||||
|
||||
Expected result:
|
||||
- StudentRegistrationRequested is triggered and executes TestStopRegisterPipelineStep.
|
||||
- The user's registration stops.
|
||||
"""
|
||||
response = self.client.post(self.url, self.user_info)
|
||||
|
||||
self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
|
||||
|
||||
@override_settings(OPEN_EDX_FILTERS_CONFIG={})
|
||||
def test_register_without_filter_configuration(self):
|
||||
"""
|
||||
Test usual registration process, without filter's intervention.
|
||||
|
||||
Expected result:
|
||||
- StudentRegistrationRequested does not have any effect on the registration process.
|
||||
- The registration process ends successfully.
|
||||
"""
|
||||
self.client.post(self.url, self.user_info)
|
||||
|
||||
user = User.objects.filter(username=f"{self.user_info.get('username')}")
|
||||
self.assertTrue(user)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class LoginFiltersTest(UserAPITestCase):
|
||||
"""
|
||||
Tests for the Open edX Filters associated with the user login process.
|
||||
|
||||
This class guarantees that the following filters are triggered during the user's login:
|
||||
|
||||
- StudentLoginRequested
|
||||
"""
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super().setUp()
|
||||
self.user = UserFactory.create(
|
||||
username="test",
|
||||
email="test@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.user_profile = UserProfileFactory.create(user=self.user, name="Test Example")
|
||||
self.url = reverse('login_api')
|
||||
|
||||
@override_settings(
|
||||
OPEN_EDX_FILTERS_CONFIG={
|
||||
"org.openedx.learning.student.login.requested.v1": {
|
||||
"pipeline": [
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestLoginPipelineStep",
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestAnotherLoginPipelineStep",
|
||||
],
|
||||
"fail_silently": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_login_filter_executed(self):
|
||||
"""
|
||||
Test whether the student login filter is triggered before the user's
|
||||
login process.
|
||||
|
||||
Expected result:
|
||||
- StudentLoginRequested is triggered and executes TestLoginPipelineStep.
|
||||
- The user's profile is updated.
|
||||
"""
|
||||
data = {
|
||||
"email": "test@example.com",
|
||||
"password": "password",
|
||||
}
|
||||
|
||||
self.client.post(self.url, data)
|
||||
|
||||
user = User.objects.get(username=self.user.username)
|
||||
self.assertDictEqual({"logged_in": True, "another_logged_in": True}, user.profile.get_meta())
|
||||
|
||||
@override_settings(
|
||||
OPEN_EDX_FILTERS_CONFIG={
|
||||
"org.openedx.learning.student.login.requested.v1": {
|
||||
"pipeline": [
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestLoginPipelineStep",
|
||||
"openedx.core.djangoapps.user_authn.views.tests.test_filters.TestStopLoginPipelineStep",
|
||||
],
|
||||
"fail_silently": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_login_filter_prevent_login(self):
|
||||
"""
|
||||
Test prevent the user's login through a pipeline step.
|
||||
|
||||
Expected result:
|
||||
- StudentLoginRequested is triggered and executes TestStopLoginPipelineStep.
|
||||
- Test prevent the user's login through a pipeline step.
|
||||
"""
|
||||
data = {
|
||||
"email": "test@example.com",
|
||||
"password": "password",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
|
||||
|
||||
@override_settings(OPEN_EDX_FILTERS_CONFIG={})
|
||||
def test_login_without_filter_configuration(self):
|
||||
"""
|
||||
Test usual login process, without filter's intervention.
|
||||
|
||||
Expected result:
|
||||
- StudentLoginRequested does not have any effect on the login process.
|
||||
- The login process ends successfully.
|
||||
"""
|
||||
data = {
|
||||
"email": "test@example.com",
|
||||
"password": "password",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(status.HTTP_200_OK, response.status_code)
|
||||
@@ -120,6 +120,7 @@ nodeenv # Utility for managing Node.js environments;
|
||||
oauthlib # OAuth specification support for authenticating via LTI or other Open edX services
|
||||
openedx-calc # Library supporting mathematical calculations for Open edX
|
||||
openedx-events # Open edX Events from Hooks Extension Framework (OEP-50)
|
||||
openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50)
|
||||
ora2
|
||||
piexif # Exif image metadata manipulation, used in the profile_images app
|
||||
Pillow # Image manipulation library; used for course assets, profile images, invoice PDFs, etc.
|
||||
|
||||
@@ -226,6 +226,7 @@ django==3.2.11
|
||||
# jsonfield
|
||||
# lti-consumer-xblock
|
||||
# openedx-events
|
||||
# openedx-filters
|
||||
# ora2
|
||||
# super-csv
|
||||
# xss-utils
|
||||
@@ -701,6 +702,8 @@ openedx-calc==2.0.1
|
||||
# via -r requirements/edx/base.in
|
||||
openedx-events==0.7.1
|
||||
# via -r requirements/edx/base.in
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/base.in
|
||||
ora2==3.7.8
|
||||
# via -r requirements/edx/base.in
|
||||
packaging==21.3
|
||||
|
||||
@@ -304,6 +304,7 @@ django==3.2.11
|
||||
# jsonfield
|
||||
# lti-consumer-xblock
|
||||
# openedx-events
|
||||
# openedx-filters
|
||||
# ora2
|
||||
# super-csv
|
||||
# xss-utils
|
||||
@@ -935,6 +936,8 @@ openedx-calc==2.0.1
|
||||
# via -r requirements/edx/testing.txt
|
||||
openedx-events==0.7.1
|
||||
# via -r requirements/edx/testing.txt
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/testing.txt
|
||||
ora2==3.7.8
|
||||
# via -r requirements/edx/testing.txt
|
||||
packaging==21.3
|
||||
|
||||
@@ -292,6 +292,7 @@ distlib==0.3.4
|
||||
# jsonfield
|
||||
# lti-consumer-xblock
|
||||
# openedx-events
|
||||
# openedx-filters
|
||||
# ora2
|
||||
# super-csv
|
||||
# xss-utils
|
||||
@@ -885,6 +886,8 @@ openedx-calc==2.0.1
|
||||
# via -r requirements/edx/base.txt
|
||||
openedx-events==0.7.1
|
||||
# via -r requirements/edx/base.txt
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/base.txt
|
||||
ora2==3.7.8
|
||||
# via -r requirements/edx/base.txt
|
||||
packaging==21.3
|
||||
|
||||
Reference in New Issue
Block a user