From e911956ab9f6248453159963ec6a015ac816d5f3 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:02:32 +0000 Subject: [PATCH] feat: add CourseModeCheckoutStarted filter --- openedx_filters/learning/filters.py | 389 ++++++++++++++++-- .../learning/tests/test_filters.py | 343 ++++++++++++--- 2 files changed, 636 insertions(+), 96 deletions(-) diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py index 8ca1f2b..7b2e94b 100644 --- a/openedx_filters/learning/filters.py +++ b/openedx_filters/learning/filters.py @@ -2,6 +2,7 @@ Package where filters related to the learning architectural subdomain are implemented. """ +from datetime import datetime from typing import Any, Optional from django.db.models.query import QuerySet @@ -25,7 +26,7 @@ class AccountSettingsRenderStarted(OpenEdxPublicFilter): org.openedx.learning.student.settings.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/user_api/accounts/settings_views.py - Function or Method: account_settings @@ -142,7 +143,7 @@ class StudentRegistrationRequested(OpenEdxPublicFilter, SensitiveDataManagementM org.openedx.learning.student.registration.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/user_authn/views/register.py - Function or Method: RegistrationView.post """ @@ -195,7 +196,7 @@ class StudentLoginRequested(OpenEdxPublicFilter): org.openedx.learning.student.login.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/user_authn/views/login.py - Function or Method: login_user """ @@ -260,7 +261,7 @@ class CourseEnrollmentStarted(OpenEdxPublicFilter): org.openedx.learning.course.enrollment.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: common/djangoapps/student/models/course_enrollment.py - Function or Method: enroll """ @@ -310,7 +311,7 @@ class CourseUnenrollmentStarted(OpenEdxPublicFilter): org.openedx.learning.course.unenrollment.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: common/djangoapps/student/models/course_enrollment.py - Function or Method: unenroll """ @@ -340,6 +341,62 @@ def run_filter(cls, enrollment: Any) -> Any: return data.get("enrollment") +class CourseEnrollmentViewStarted(OpenEdxPublicFilter): + """ + Filter used to perform pre-enrollment processing during the enrollment REST API view. + + Purpose: + This filter is triggered when a user initiates enrollment via the enrollment REST API view, + just before the enrollment is created, allowing pipeline steps to perform pre-enrollment + processing scoped to the view layer. + + Filter Type: + org.openedx.learning.course.enrollment.view.started.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: openedx/core/djangoapps/enrollments/views.py + - Function or Method: EnrollmentListView.post + """ + + filter_type = "org.openedx.learning.course.enrollment.view.started.v1" + + class PreventEnrollment(OpenEdxFilterException): + """ + Raise to prevent the enrollment process from continuing. + """ + + @classmethod + def run_filter( + cls, user: Any, course_key: CourseKey, requester_is_backend_service: bool + ) -> tuple[Any, CourseKey, bool]: + """ + Process the user, course_key, and requester_is_backend_service using the configured + pipeline steps to preempt the enrollment process. + + Arguments: + user (User): Django User enrolling in the course. + course_key (CourseKey): course key associated with the enrollment. + requester_is_backend_service (bool): if request was made by a server with an API key. + + Returns: + tuple[Any, CourseKey, bool]: + - User: Django User object. + - CourseKey: course key associated with the enrollment. + - bool: if request was made by a server with an API key. + """ + data = super().run_pipeline( + user=user, + course_key=course_key, + requester_is_backend_service=requester_is_backend_service + ) + return ( + data["user"], + data["course_key"], + data["requester_is_backend_service"], + ) + + class CertificateCreationRequested(OpenEdxPublicFilter): """ Filter used to modify the certificate creation process for a given user in a course. @@ -352,7 +409,7 @@ class CertificateCreationRequested(OpenEdxPublicFilter): org.openedx.learning.certificate.creation.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/certificates/generated_certificate.py - Function or Method: _generate_certificate_task """ @@ -427,7 +484,7 @@ class CertificateRenderStarted(OpenEdxPublicFilter): org.openedx.learning.certificate.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/certificates/views/webview.py - Function or Method: render_html_view """ @@ -532,7 +589,7 @@ class CohortChangeRequested(OpenEdxPublicFilter): org.openedx.learning.cohort.change.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/course_groups/models.py - Function or Method: assign """ @@ -576,7 +633,7 @@ class CohortAssignmentRequested(OpenEdxPublicFilter): org.openedx.learning.cohort.assignment.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/course_groups/models.py - Function or Method: assign """ @@ -624,7 +681,7 @@ class CourseAboutRenderStarted(OpenEdxPublicFilter): org.openedx.learning.course_about.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/courseware/views/views.py - Function or Method: course_about """ @@ -737,7 +794,7 @@ class DashboardRenderStarted(OpenEdxPublicFilter): org.openedx.learning.dashboard.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: common/djangoapps/student/views/dashboard.py - Function or Method: student_dashboard @@ -855,7 +912,7 @@ class VerticalBlockChildRenderStarted(OpenEdxPublicFilter): org.openedx.learning.vertical_block_child.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: xmodule/vertical_block.py - Function or Method: VerticalBlock._student_or_public_view """ @@ -940,7 +997,7 @@ class RenderXBlockStarted(OpenEdxPublicFilter): org.openedx.learning.xblock.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/courseware/views/views.py - Function or Method: render_xblock """ @@ -1011,7 +1068,7 @@ class VerticalBlockRenderCompleted(OpenEdxPublicFilter): org.openedx.learning.vertical_block.render.completed.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: xmodule/vertical_block.py - Function or Method: VerticalBlock._student_or_public_view """ @@ -1038,14 +1095,14 @@ def run_filter( Process the inputs using the configured pipeline steps to modify the rendering of a vertical block. Arguments: - block (VerticalBlock): The VeriticalBlock instance which is being rendered. + block (VerticalBlock): The VerticalBlock instance which is being rendered. fragment (web_fragments.Fragment): The web-fragment containing the rendered content of VerticalBlock. context (dict): rendering context values like is_mobile_app, show_title..etc. view (str): the rendering view. Can be either 'student_view', or 'public_view'. Returns: - tuple[VeticalBlock, web_fragments.Fragment, dict, str]: - - VerticalBlock: The VeriticalBlock instance which is being rendered. + tuple[VerticalBlock, web_fragments.Fragment, dict, str]: + - VerticalBlock: The VerticalBlock instance which is being rendered. - web_fragments.Fragment: The web-fragment containing the rendered content of VerticalBlock. - dict: rendering context values like is_mobile_app, show_title..etc. - str: the rendering view. Can be either 'student_view', or 'public_view'. @@ -1066,7 +1123,7 @@ class CourseHomeUrlCreationStarted(OpenEdxPublicFilter): org.openedx.learning.course.homepage.url.creation.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/features/course_experience/__init__.py - Function or Method: course_home_url """ @@ -1103,7 +1160,7 @@ class CourseEnrollmentAPIRenderStarted(OpenEdxPublicFilter): org.openedx.learning.home.enrollment.api.rendered.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/learner_home/serializers.py - Function or Method: EnrollmentSerializer.to_representation """ @@ -1144,7 +1201,7 @@ class CourseRunAPIRenderStarted(OpenEdxPublicFilter): org.openedx.learning.home.courserun.api.rendered.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/learner_home/serializers.py - Function or Method: CourseRunSerializer.to_representation """ @@ -1183,7 +1240,7 @@ class InstructorDashboardRenderStarted(OpenEdxPublicFilter): org.openedx.learning.instructor.dashboard.render.started.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/instructor/views/instructor_dashboard.py - Function or Method: instructor_dashboard_2 """ @@ -1304,7 +1361,7 @@ class InstructorDashboardTabsRequested(OpenEdxPublicFilter): org.openedx.learning.instructor.dashboard.tabs.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/instructor/views/serializers_v2.py - Function or Method: CourseInformationSerializerV2.get_tabs """ @@ -1339,7 +1396,7 @@ def run_filter( tabs: list, user: Any, course_key: CourseKey - ) -> list | None: + ) -> tuple[list | None, Any | None, CourseKey | None]: """ Process the tabs list using the configured pipeline steps to modify instructor dashboard tabs. Arguments: @@ -1347,14 +1404,17 @@ def run_filter( user (User): Django User object (usually an instructor or staff member). course_key (CourseKey): Course key for the instructor dashboard. Returns: - list | None: Tab dictionaries, possibly modified by pipeline steps, or None if not provided. + tuple[list | None, Any | None, CourseKey | None]: + - list | None: Tab dictionaries, possibly modified by pipeline steps, or None if not provided. + - Any | None: User object, possibly modified by pipeline steps, or None if not provided. + - CourseKey | None: Course key, possibly modified by pipeline steps, or None if not provided. """ data = super().run_pipeline( tabs=tabs, user=user, course_key=course_key ) - return data.get("tabs") + return data.get("tabs"), data.get("user"), data.get("course_key") class ORASubmissionViewRenderStarted(OpenEdxPublicFilter): @@ -1427,7 +1487,7 @@ class IDVPageURLRequested(OpenEdxPublicFilter): org.openedx.learning.idv.page.url.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: lms/djangoapps/verify_student/services.py - Function or Method: XBlockVerificationService.get_verify_location """ @@ -1461,7 +1521,7 @@ class CourseAboutPageURLRequested(OpenEdxPublicFilter): org.openedx.learning.course_about.page.url.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: common/djangoapps/util/course.py - Function or Method: get_link_for_about_page """ @@ -1493,13 +1553,13 @@ class ScheduleQuerySetRequested(OpenEdxPublicFilter): Purpose: This filter is triggered when a QuerySet of Schedules is requested, allowing the filter to act on the schedules data. If you want to know more about the Schedules feature, please refer to the official documentation: - - https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/schedules#readme + - https://github.com/openedx/openedx-platform/tree/master/openedx/core/djangoapps/schedules#readme Filter Type: org.openedx.learning.schedule.queryset.requested.v1 Trigger: - - Repository: openedx/edx-platform + - Repository: openedx/openedx-platform - Path: openedx/core/djangoapps/schedules/resolvers.py - Function or Method: BinnedSchedulesBaseResolver.get_schedules_with_target_date_by_bin_and_orgs """ @@ -1598,3 +1658,274 @@ def run_filter(cls, context: dict, user_id: int, course_id: str) -> tuple[dict, """ data = super().run_pipeline(context=context, user_id=user_id, course_id=course_id) return data["context"], data["user_id"], data["course_id"] + + +class CoursewareViewStarted(OpenEdxPublicFilter): + """ + Filter triggered before a courseware view is rendered, allowing pipeline steps to redirect. + + Purpose: + Invoked before a courseware view renders. A pipeline step that wants to redirect + the user raises the nested ``RedirectToUrl`` exception with the target URL. The + platform decorator catches the exception and calls ``django.shortcuts.redirect``. + + The first pipeline step to raise wins; later steps do not run. + + Filter Type: + org.openedx.learning.courseware.view.started.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: lms/djangoapps/courseware/decorators.py + - Function or Method: courseware_view_hooks + """ + + filter_type = "org.openedx.learning.courseware.view.started.v1" + + class RedirectToUrl(OpenEdxFilterException): + """Raised by a pipeline step to redirect the user before the courseware view renders.""" + + def __init__(self, message: str, redirect_to: str) -> None: + """Require a redirect URL on the exception instance.""" + super().__init__(message=message, redirect_to=redirect_to) + + @classmethod + def run_filter(cls, course_key: CourseKey, view_name: str) -> tuple[CourseKey, str]: + """ + Run the pipeline so plugins can redirect the user. + + Arguments: + course_key (CourseKey): the course key for the view being accessed. + view_name (str): name of the view function being accessed (e.g. 'index', 'progress'), + used by pipeline steps for logging/auditing purposes. + + Returns: + tuple[CourseKey, str]: ``(course_key, view_name)``, unchanged. + + Note: + Pipeline steps that need the current request should obtain it via + ``crum.get_current_request()`` rather than expecting it as a kwarg. + """ + data = super().run_pipeline(course_key=course_key, view_name=view_name) + return data["course_key"], data["view_name"] + + +class CourseStartDateValidationFailed(OpenEdxPublicFilter): + """ + Filter triggered from the failure branch of the course start-date access check. + + Purpose: + Invoked after start-date validation has already failed for the base case, + allowing pipeline steps to substitute a more specific access-error payload. + A step that wants to override the default error raises the nested + ``OverrideStartDateError`` exception; the platform catches it and builds a + ``StartDateFiltersError`` from the exception's fields. + + The first pipeline step to raise wins; later steps do not run. + + Filter Type: + org.openedx.learning.course.start_date.validation_failed.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: lms/djangoapps/courseware/access_utils.py + - Function or Method: check_start_date + """ + + filter_type = "org.openedx.learning.course.start_date.validation_failed.v1" + + class OverrideStartDateError(OpenEdxFilterException): + """ + Raised by a pipeline step to substitute a more specific start-date access error. + + The caller decides whether to surface ``user_message`` in the UI. + """ + + def __init__( + self, + message: str, + error_code: str, + developer_message: str, + user_message: str, + ) -> None: + """Store error fields on the exception instance.""" + super().__init__(message=message) + # Explicit assignments keep pylint's no-member check happy; the base + # class would set these via **kwargs setattr but pylint can't see that. + self.error_code = error_code + self.developer_message = developer_message + self.user_message = user_message + + @classmethod + def run_filter( + cls, + course_key: CourseKey, + start_date: datetime, + ) -> tuple[CourseKey, datetime]: + """ + Run the pipeline so plugins can substitute a more specific start-date error. + + Arguments: + course_key (CourseKey): the course key for the view being accessed. + start_date (datetime): the course start date. + + Returns: + tuple[CourseKey, datetime]: the inputs unchanged. + """ + data = super().run_pipeline( + course_key=course_key, + start_date=start_date, + ) + return data["course_key"], data["start_date"] + + +class CoursewareAccessChecksRequested(OpenEdxPublicFilter): + """ + Filter triggered during courseware access checks so plugins can deny access. + + Purpose: + Invoked from the courseware access-check flow after platform-internal + checks have passed. Pipeline steps that wish to deny access raise the + nested ``PreventCoursewareAccess`` exception, which the framework + propagates back to the caller regardless of ``fail_silently``. Denials + surfaced through this filter are treated as priority — i.e. they cannot + be bypassed by staff users. + + The first pipeline step to raise wins; later steps do not run. + + Filter Type: + org.openedx.learning.courseware.access_checks.requested.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: lms/djangoapps/courseware/courses.py + - Function or Method: check_course_access + """ + + filter_type = "org.openedx.learning.courseware.access_checks.requested.v1" + + class PreventCoursewareAccess(OpenEdxFilterException): + """ + Raised by a pipeline step to deny courseware access. + """ + + def __init__( + self, + message: str, + error_code: str, + developer_message: str, + user_message: str, + ) -> None: + """Store error fields on the exception instance.""" + super().__init__(message=message) + # Explicit assignments keep pylint's no-member check happy; the base + # class would set these via **kwargs setattr but pylint can't see that. + self.error_code = error_code + self.developer_message = developer_message + self.user_message = user_message + + @classmethod + def run_filter(cls, user: Any, course_key: CourseKey) -> tuple[Any, CourseKey]: + """ + Run the pipeline so plugins can deny access. + + Arguments: + user: the user whose access is being checked. + course_key: the course key being accessed. + + Returns: + tuple[Any, CourseKey]: ``(user, course_key)``, unchanged. + """ + data = super().run_pipeline(user=user, course_key=course_key) + return data["user"], data["course_key"] + + +class DiscountEligibilityCheckRequested(OpenEdxPublicFilter): + """ + Filter used to allow plugins to mark a user as ineligible for a course discount. + + Purpose: + This filter is triggered during discount applicability checks, just before the + final eligibility decision is returned to the caller. Pipeline steps may raise + ``DiscountIneligible`` to exclude a user from receiving a discount. + + Filter Type: + org.openedx.learning.discount.eligibility.check.requested.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: openedx/features/discounts/applicability.py + - Function or Method: can_receive_discount, can_show_streak_discount_coupon + """ + + filter_type = "org.openedx.learning.discount.eligibility.check.requested.v1" + + class DiscountIneligible(OpenEdxFilterException): + """ + Raised by a pipeline step to indicate user is ineligible for discounts + """ + + @classmethod + def run_filter( + cls, + user: Any, + course_key: CourseKey, + ) -> tuple[Any, CourseKey]: + """ + Process the inputs using the configured pipeline steps. + + Arguments: + user (User): the Django User being checked for discount eligibility. + course_key (CourseKey or course object): identifies the course. + + Returns: + tuple[User, CourseKey, bool]: + - User: the Django User object (unchanged). + - CourseKey: the course key (unchanged). + - bool: the (possibly overridden) eligibility flag. + + Raises: + DiscountIneligible: when a pipeline step determines the user is + not eligible for a discount and halts further processing. + """ + data = super().run_pipeline(user=user, course_key=course_key) + return data["user"], data["course_key"] + + +class CourseModeCheckoutStarted(OpenEdxPublicFilter): + """ + Filter used to enrich the course mode checkout context before the checkout flow begins. + + Purpose: + This filter is triggered when a user selects a course mode and the checkout process + starts. Pipeline steps can modify the context dict to inject additional data required + by external systems (e.g. pricing APIs) to customize the checkout experience. + + Filter Type: + org.openedx.learning.course_mode.checkout.started.v1 + + Trigger: + - Repository: openedx/openedx-platform + - Path: common/djangoapps/course_modes/views.py + - Function or Method: ChooseModeView.post + """ + + filter_type = "org.openedx.learning.course_mode.checkout.started.v1" + + @classmethod + def run_filter(cls, context: dict, request: Any, course_mode: Any) -> tuple[dict | None, Any, Any]: + """ + Process the checkout context through the configured pipeline steps. + + Arguments: + context (dict): the checkout context dict. + request (HttpRequest): the current HTTP request. + course_mode (CourseMode): the selected course mode object. + + Returns: + context: the (possibly enriched) checkout context dict + request (HttpRequest): the current HTTP request. + course_mode (CourseMode): the selected course mode object + """ + data = super().run_pipeline(context=context, request=request, course_mode=course_mode) + return data.get("context"), data.get("request"), data.get("course_mode") diff --git a/openedx_filters/learning/tests/test_filters.py b/openedx_filters/learning/tests/test_filters.py index b537e87..63118f6 100644 --- a/openedx_filters/learning/tests/test_filters.py +++ b/openedx_filters/learning/tests/test_filters.py @@ -1,11 +1,13 @@ """ Tests for learning subdomain filters. """ +from datetime import datetime from unittest.mock import Mock, patch # Ignore the type error for ddt import since it is not recognized by mypy. from ddt import data, ddt, unpack # type: ignore from django.test import TestCase +from opaque_keys.edx.keys import CourseKey from openedx_filters.learning.filters import ( AccountSettingsReadOnlyFieldsRequested, @@ -19,10 +21,16 @@ CourseEnrollmentAPIRenderStarted, CourseEnrollmentQuerysetRequested, CourseEnrollmentStarted, + CourseEnrollmentViewStarted, CourseHomeUrlCreationStarted, + CourseModeCheckoutStarted, CourseRunAPIRenderStarted, + CourseStartDateValidationFailed, CourseUnenrollmentStarted, + CoursewareAccessChecksRequested, + CoursewareViewStarted, DashboardRenderStarted, + DiscountEligibilityCheckRequested, GradeEventContextRequested, IDVPageURLRequested, InstructorDashboardRenderStarted, @@ -72,10 +80,7 @@ def test_certificate_creation_requested(self): generation_mode, ) - self.assertTupleEqual( - (user, course_key, mode, status, grade, generation_mode,), - result, - ) + assert result == (user, course_key, mode, status, grade, generation_mode,) def test_certificate_render_started(self): """ @@ -92,7 +97,7 @@ def test_certificate_render_started(self): result = CertificateRenderStarted.run_filter(context, template_name) - self.assertTupleEqual((context, template_name,), result) + assert result == (context, template_name,) @data( (CertificateRenderStarted.RedirectToPage, {"redirect_to": "custom-certificate.pdf"}), @@ -110,7 +115,7 @@ def test_halt_certificate_process(self, CertificateException, attributes): """ exception = CertificateException(message="You can't generate certificate", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() @ddt @@ -139,7 +144,7 @@ def test_student_registration_requested(self): form_data = StudentRegistrationRequested.run_filter(expected_form_data) - self.assertEqual(expected_form_data, form_data) + assert expected_form_data == form_data @patch( "openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline", @@ -177,7 +182,7 @@ def test_student_registration_protected(self): } ) - self.assertEqual(expected_form_data, form_data) + assert expected_form_data == form_data def test_student_login_requested(self): """ @@ -191,7 +196,7 @@ def test_student_login_requested(self): user = StudentLoginRequested.run_filter(expected_user) - self.assertEqual(expected_user, user) + assert expected_user == user @data( ( @@ -219,7 +224,7 @@ def test_halt_student_auth_process(self, auth_exception, attributes): """ exception = auth_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() @ddt @@ -231,6 +236,7 @@ class TestEnrollmentFilters(TestCase): - CourseEnrollmentStarted - CourseUnenrollmentStarted - CourseEnrollmentQuerysetRequested + - CourseEnrollmentViewStarted """ def test_course_enrollment_started(self): @@ -247,7 +253,7 @@ def test_course_enrollment_started(self): result = CourseEnrollmentStarted.run_filter(user, course_key, mode) - self.assertTupleEqual((user, course_key, mode,), result) + assert result == (user, course_key, mode,) def test_course_unenrollment_started(self): """ @@ -261,7 +267,23 @@ def test_course_unenrollment_started(self): enrollment = CourseUnenrollmentStarted.run_filter(expected_enrollment) - self.assertEqual(expected_enrollment, enrollment) + assert expected_enrollment == enrollment + + def test_course_enrollment_view_started(self): + """ + Test CourseEnrollmentViewStarted filter behavior under normal conditions. + + Expected behavior: + - The filter must have the signature specified. + - The filter should return user, course_key, and requester_is_backend_service, in that order. + """ + user = Mock() + course_key = Mock() + requester_is_backend_service = True + + result = CourseEnrollmentViewStarted.run_filter(user, course_key, requester_is_backend_service) + + assert result == (user, course_key, requester_is_backend_service) @data( (CourseEnrollmentStarted.PreventEnrollment, {"message": "Can't enroll into course."}), @@ -279,7 +301,7 @@ def test_halt_enrollment_process(self, enrollment_exception, attributes): """ exception = enrollment_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_course_enrollments_requested(self): """ @@ -292,7 +314,7 @@ def test_course_enrollments_requested(self): enrollments = CourseEnrollmentQuerysetRequested.run_filter(expected_enrollments) - self.assertEqual(expected_enrollments, enrollments) + assert expected_enrollments == enrollments @data( ( @@ -310,7 +332,18 @@ def test_halt_queryset_request(self, request_exception, attributes): """ exception = request_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() + + def test_halt_course_enrollment_view_process(self): + """ + Test CourseEnrollmentViewStarted.PreventEnrollment exception handling. + + Expected behavior: + - The exception must carry the message attribute specified. + """ + test_message = "Enterprise enrollment processing failed" + exception = CourseEnrollmentViewStarted.PreventEnrollment(message=test_message) + assert exception.message == test_message @ddt @@ -346,7 +379,7 @@ def test_course_about_render_started(self): """ result = CourseAboutRenderStarted.run_filter(self.context, self.template_name) - self.assertTupleEqual((self.context, self.template_name,), result) + assert result == (self.context, self.template_name,) def test_dashboard_render_started(self): """ @@ -358,14 +391,14 @@ def test_dashboard_render_started(self): """ result = DashboardRenderStarted.run_filter(self.context, self.template_name) - self.assertTupleEqual((self.context, self.template_name,), result) + assert result == (self.context, self.template_name,) @data( (DashboardRenderStarted.RedirectToPage, {"redirect_to": "custom-dashboard.html"}), ( DashboardRenderStarted.RenderInvalidDashboard, { - "dashboard_template": "custom-dasboard.html", + "dashboard_template": "custom-dashboard.html", "template_context": {"user": Mock()}, } ), @@ -381,7 +414,7 @@ def test_halt_dashboard_render(self, dashboard_exception, attributes): """ exception = dashboard_exception(message="You can't access the dashboard", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() @data( (CourseAboutRenderStarted.RedirectToPage, {"redirect_to": "custom-course-about.html"}), @@ -404,7 +437,7 @@ def test_halt_course_about_render(self, course_about_exception, attributes): """ exception = course_about_exception(message="You can't access the course about", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_verticalblock_child_render_started(self): """ @@ -418,19 +451,19 @@ def test_verticalblock_child_render_started(self): context = { "is_mobile_view": False, "username": "edx", - "child_of_veritcal": True, + "child_of_vertical": True, "bookmarked": False } result = VerticalBlockChildRenderStarted.run_filter(block, context) - self.assertTupleEqual((block, context,), result) + assert result == (block, context,) @data( ( VerticalBlockChildRenderStarted.PreventChildBlockRender, { - "message": "Assessement question not available for Audit students" + "message": "Assessment question not available for Audit students" } ) ) @@ -444,7 +477,7 @@ def test_halt_vertical_child_block_render(self, block_render_exception, attribut """ exception = block_render_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_vertical_block_render_completed(self): """ @@ -465,7 +498,7 @@ def test_vertical_block_render_completed(self): result = VerticalBlockRenderCompleted.run_filter(block, fragment, context, view) - self.assertTupleEqual((block, fragment, context, view), result) + assert result == (block, fragment, context, view) @data( ( @@ -485,7 +518,7 @@ def test_halt_vertical_block_render(self, render_exception, attributes): """ exception = render_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_xblock_render_started(self): """ @@ -507,7 +540,7 @@ def test_xblock_render_started(self): result = VerticalBlockChildRenderStarted.run_filter(context, student_view_context) - self.assertTupleEqual((context, student_view_context), result) + assert result == (context, student_view_context) @data( ( @@ -527,7 +560,7 @@ def test_halt_xblock_render(self, xblock_render_exception, attributes): """ exception = xblock_render_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() @data( ( @@ -547,7 +580,7 @@ def test_halt_xblock_render_custom_response(self, xblock_render_exception, attri """ exception = xblock_render_exception(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_account_settings_render_started(self): """ @@ -564,7 +597,7 @@ def test_account_settings_render_started(self): result, _ = AccountSettingsRenderStarted.run_filter(context=context, template_name=None) - self.assertEqual(result, context) + assert result == context @data( (AccountSettingsRenderStarted.RedirectToPage, {"redirect_to": "custom_account_settings.html"}), @@ -580,7 +613,7 @@ def test_halt_account_rendering_process(self, AccountSettingsException, attribut """ exception = AccountSettingsException(message="You can't access this page", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_instructor_dashboard_render_started(self): """ @@ -592,14 +625,14 @@ def test_instructor_dashboard_render_started(self): """ result = InstructorDashboardRenderStarted.run_filter(self.context, self.template_name) - self.assertTupleEqual((self.context, self.template_name,), result) + assert result == (self.context, self.template_name,) @data( (InstructorDashboardRenderStarted.RedirectToPage, {"redirect_to": "custom-dashboard.html"}), ( InstructorDashboardRenderStarted.RenderInvalidDashboard, { - "instructor_template": "custom-dasboard.html", + "instructor_template": "custom-dashboard.html", "template_context": {"course": Mock()}, } ), @@ -615,7 +648,7 @@ def test_halt_instructor_dashboard_render(self, dashboard_exception, attributes) """ exception = dashboard_exception(message="You can't access the dashboard", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() def test_ora_submission_view_render_started(self): """ @@ -627,7 +660,7 @@ def test_ora_submission_view_render_started(self): """ result = ORASubmissionViewRenderStarted.run_filter(self.context, self.template_name) - self.assertTupleEqual((self.context, self.template_name,), result) + assert result == (self.context, self.template_name,) @data( ( @@ -645,7 +678,7 @@ def test_halt_ora_submission_view_render(self, dashboard_exception, attributes): """ exception = dashboard_exception(message="You can't access the view", **attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() class TestCohortFilters(TestCase): @@ -669,7 +702,7 @@ def test_cohort_change_requested(self): result = CohortChangeRequested.run_filter(current_membership, target_cohort) - self.assertTupleEqual((current_membership, target_cohort,), result) + assert result == (current_membership, target_cohort,) def test_cohort_assignment_requested(self): """ @@ -683,7 +716,7 @@ def test_cohort_assignment_requested(self): result = CohortAssignmentRequested.run_filter(user, target_cohort) - self.assertTupleEqual((user, target_cohort,), result) + assert result == (user, target_cohort,) class TestFederatedContentFilters(TestCase): @@ -706,7 +739,7 @@ def test_course_homeurl_creation_started(self): result = CourseHomeUrlCreationStarted.run_filter(course_key, course_home_url) - self.assertTupleEqual((course_key, course_home_url,), result) + assert result == (course_key, course_home_url,) def test_course_enrollment_api_render_started(self): """ @@ -720,7 +753,7 @@ def test_course_enrollment_api_render_started(self): result = CourseEnrollmentAPIRenderStarted.run_filter(course_key, serialized_enrollment) - self.assertTupleEqual((course_key, serialized_enrollment,), result) + assert result == (course_key, serialized_enrollment,) def test_course_run_api_render_started(self): """ @@ -734,7 +767,7 @@ def test_course_run_api_render_started(self): result = CourseRunAPIRenderStarted.run_filter(serialized_courserun) - self.assertEqual(serialized_courserun, result) + assert serialized_courserun == result class TestIDVFilters(TestCase): @@ -757,7 +790,7 @@ def test_idv_page_url_requested(self): result = IDVPageURLRequested.run_filter(url) - self.assertEqual(url, result) + assert url == result class TestCourseAboutPageURLRequested(TestCase): @@ -779,8 +812,8 @@ def test_lms_page_url_requested(self): url_result, org_result = CourseAboutPageURLRequested.run_filter(url, org) - self.assertEqual(url, url_result) - self.assertEqual(org, org_result) + assert url == url_result + assert org == org_result @ddt @@ -803,7 +836,7 @@ def test_schedule_requested(self): result = ScheduleQuerySetRequested.run_filter(schedules) - self.assertEqual(schedules, result) + assert schedules == result class TestGradeEventContextRequestedFilter(TestCase): @@ -830,18 +863,15 @@ def test_run_filter_returns_context_unchanged_when_no_pipeline(self): course_id=course_id, ) - self.assertEqual(result_context, context) - self.assertEqual(result_user_id, user_id) - self.assertEqual(result_course_id, course_id) + assert result_context == context + assert result_user_id == user_id + assert result_course_id == course_id def test_filter_type(self): """ Confirm the filter type string is correct. """ - self.assertEqual( - GradeEventContextRequested.filter_type, - "org.openedx.learning.grade.context.requested.v1", - ) + assert GradeEventContextRequested.filter_type == "org.openedx.learning.grade.context.requested.v1" class TestAccountSettingsReadOnlyFieldsRequestedFilter(TestCase): @@ -860,14 +890,12 @@ def test_run_filter_returns_inputs_unchanged_when_no_pipeline(self): readonly_fields=readonly_fields, user=user ) - self.assertEqual(result_fields, readonly_fields) - self.assertEqual(result_user, user) + assert result_fields == readonly_fields + assert result_user == user def test_filter_type(self): - self.assertEqual( - AccountSettingsReadOnlyFieldsRequested.filter_type, - "org.openedx.learning.account.settings.read_only_fields.requested.v1", - ) + filter_type = "org.openedx.learning.account.settings.read_only_fields.requested.v1" + assert AccountSettingsReadOnlyFieldsRequested.filter_type == filter_type @ddt @@ -897,18 +925,18 @@ def test_run_filter_returns_unchanged_tabs_when_no_pipeline(self): with patch("openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline") as mock_run_pipeline: mock_run_pipeline.return_value = {"tabs": tabs, "user": user, "course_key": course_key} - result_tabs = InstructorDashboardTabsRequested.run_filter( + result_tabs, result_user, result_course_key = InstructorDashboardTabsRequested.run_filter( tabs=tabs, user=user, course_key=course_key ) - self.assertEqual(result_tabs, tabs) + assert result_tabs == tabs + assert result_user == user + assert result_course_key == course_key def test_filter_type(self): """Test that the filter type is properly set.""" - self.assertEqual( - InstructorDashboardTabsRequested.filter_type, - "org.openedx.learning.instructor.dashboard.tabs.requested.v1", - ) + filter_type = "org.openedx.learning.instructor.dashboard.tabs.requested.v1" + assert InstructorDashboardTabsRequested.filter_type == filter_type def test_run_filter_with_pipeline_returning_dict_with_tabs(self): """ @@ -930,11 +958,13 @@ def test_run_filter_with_pipeline_returning_dict_with_tabs(self): mock_run_pipeline.return_value = { "tabs": modified_tabs, "user": user, "course_key": course_key } - result_tabs = InstructorDashboardTabsRequested.run_filter( + result_tabs, result_user, result_course_key = InstructorDashboardTabsRequested.run_filter( tabs=tabs, user=user, course_key=course_key ) - self.assertEqual(result_tabs, modified_tabs) + assert result_tabs == modified_tabs + assert result_user == user + assert result_course_key == course_key @data( ( @@ -961,4 +991,183 @@ def test_prevent_tabs_generation_exception(self, exception_class, attributes): """ exception = exception_class(**attributes) - self.assertLessEqual(attributes.items(), exception.__dict__.items()) + assert attributes.items() <= exception.__dict__.items() + + +class TestCoursewareViewStarted(TestCase): + """ + Test class to verify standard behavior of the CoursewareViewStarted filter. + """ + + def test_returns_course_key_unchanged_when_no_pipeline_steps(self): + """ + Test CoursewareViewStarted filter behavior under normal conditions. + + Expected behavior: + - The filter returns ``course_key`` unchanged when no pipeline steps raise. + """ + course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + view_name = "test_view" + result_course_key, result_view_name = CoursewareViewStarted.run_filter( + course_key=course_key, + view_name=view_name, + ) + assert result_course_key == course_key + assert result_view_name == view_name + + def test_redirect_to_url_stores_url(self): + """ + Test that RedirectToUrl stores the redirect_to attribute on the exception instance. + + Expected behavior: + - Instantiating RedirectToUrl sets ``exc.redirect_to`` to the provided value. + """ + exc = CoursewareViewStarted.RedirectToUrl(message="test message", redirect_to="/some/path/") + assert exc.message == "test message" + assert exc.redirect_to == "/some/path/" + + +class TestCourseStartDateValidationFailed(TestCase): + """ + Test class to verify standard behavior of the CourseStartDateValidationFailed filter. + """ + + def test_returns_inputs_unchanged_when_no_pipeline_steps(self): + """ + Test CourseStartDateValidationFailed filter behavior under normal conditions. + + Expected behavior: + - Each input field is returned unchanged when no pipeline steps raise. + """ + course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + start_date = datetime(2026, 9, 1) + result_course_key, result_start_date = CourseStartDateValidationFailed.run_filter( + course_key=course_key, + start_date=start_date, + ) + assert result_course_key == course_key + assert result_start_date == start_date + + def test_override_start_date_error_stores_fields(self): + """ + Test that OverrideStartDateError stores all fields on the exception instance. + + Expected behavior: + - Instantiating OverrideStartDateError sets ``message``, ``error_code``, + ``developer_message``, and ``user_message`` on the instance. + """ + exc = CourseStartDateValidationFailed.OverrideStartDateError( + message="Course has not started (message).", + error_code="course_not_started", + developer_message="Course has not started (developer message).", + user_message="Course has not started (user message).", + ) + assert exc.message == "Course has not started (message)." + assert exc.error_code == "course_not_started" + assert exc.developer_message == "Course has not started (developer message)." + assert exc.user_message == "Course has not started (user message)." + + +class TestCoursewareAccessChecksRequested(TestCase): + """ + Test class to verify standard behavior of the CoursewareAccessChecksRequested filter. + """ + + def test_returns_inputs_unchanged_when_no_pipeline_steps(self): + """ + Filter passes through user and course_key when no pipeline steps are configured. + """ + user = Mock() + course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + result_user, result_course_key = CoursewareAccessChecksRequested.run_filter( + user=user, + course_key=course_key, + ) + assert result_user == user + assert result_course_key == course_key + + def test_prevent_exception_preserves_kwargs(self): + """ + PreventCoursewareAccess stores message, error_code, developer_message, and + user_message as attributes on the exception instance. + """ + exc = CoursewareAccessChecksRequested.PreventCoursewareAccess( + message="test message", + error_code="some_code", + developer_message="developer message", + user_message="user message", + ) + assert exc.message == "test message" + assert exc.error_code == "some_code" + assert exc.developer_message == "developer message" + assert exc.user_message == "user message" + + +class TestDiscountEligibilityCheckRequestedFilter(TestCase): + """ + Tests for the DiscountEligibilityCheckRequested filter. + """ + + def test_filter_type(self): + self.assertEqual( + DiscountEligibilityCheckRequested.filter_type, + "org.openedx.learning.discount.eligibility.check.requested.v1", + ) + + def test_run_filter_passes_through_user_and_course_key(self): + user = Mock() + course_key = Mock() + + returned_user, returned_course_key = ( + DiscountEligibilityCheckRequested.run_filter(user, course_key) + ) + + self.assertIs(returned_user, user) + self.assertIs(returned_course_key, course_key) + + def test_run_filter_raises_discount_ineligible_from_pipeline(self): + user = Mock() + course_key = Mock() + exc = DiscountEligibilityCheckRequested.DiscountIneligible("Enterprise contract prohibits discount.") + + with patch( + "openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline", + side_effect=exc, + ): + with self.assertRaises(DiscountEligibilityCheckRequested.DiscountIneligible): + DiscountEligibilityCheckRequested.run_filter(user, course_key) + + def test_discount_ineligible_exception_stores_message(self): + exc = DiscountEligibilityCheckRequested.DiscountIneligible("Enterprise contract prohibits discount.") + + self.assertEqual(exc.message, "Enterprise contract prohibits discount.") + + +@ddt +class TestCourseModeFilters(TestCase): + """ + Test class to verify standard behavior of the course mode filters. + + You'll find test suites for: + - `CourseModeCheckoutStarted` + """ + + def test_course_mode_checkout_started(self): + """ + Test CourseModeCheckoutStarted filter behavior under normal conditions. + + Expected behavior: + - The filter must have the signature specified. + - The filter should return the (possibly enriched) context dict. + """ + context = {"course_id": "course-v1:edX+DemoX+Demo_Course"} + request = Mock() + course_mode = Mock() + + result_context, result_request, result_course_mode = CourseModeCheckoutStarted.run_filter( + context, request, course_mode + ) + + self.assertEqual(context, result_context) + self.assertEqual(request, result_request) + self.assertEqual(course_mode, result_course_mode)