feat: add first batch of Open edX Filters

* Add PreEnrollmentFilter
* Add PreRegisterFilter
* Add PreLoginFilter
This commit is contained in:
Maria Grimaldi
2021-11-26 16:47:56 -04:00
parent 3eea5d9337
commit f29a4eef68
9 changed files with 417 additions and 0 deletions

View File

@@ -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.

View 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))

View File

@@ -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():

View File

@@ -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

View 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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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