Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ Unreleased
----------
.. scriv-insert-here

[3.5.0] - 2026-05-22

Added
~~~~~

* Added new ``DiscountEligibilityCheckRequested`` filter

[3.4.0] - 2026-05-07

Added
Expand Down
2 changes: 1 addition & 1 deletion openedx_filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from openedx_filters.filters import *

__version__ = "3.4.0"
__version__ = "3.5.0"

if sys.version_info < (3, 12): # pragma: no cover
warnings.warn(
Expand Down
45 changes: 45 additions & 0 deletions openedx_filters/learning/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1598,3 +1598,48 @@ 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 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 set
``is_eligible`` to ``False`` 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"

@classmethod
def run_filter(
cls,
user: Any,
course_key: CourseKey,
is_eligible: bool,
Copy link
Copy Markdown
Contributor Author

@pwnage101 pwnage101 May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since it only takes one pipeline step to deem the entire discount ineligible, every pipeline step implementation must take care to skip itself when is_eligible=False to prevent clobbering a prior step which set it to False. This pushes complexity into 3rd party code when there's really a much simpler solution:

We could make pipeline steps simply raise an OpenEdxFilterException exception. There are many examples in this same file, including StudentRegistrationRequested.PreventRegistration:
https://github.com/edx/openedx-filters/blob/main/openedx_filters/learning/filters.py#L161

This has the side-benefit of providing a clean way to pass error messages from the caller without polluting the run_filter() signature. See my in-progress work for a different ticket: https://github.com/openedx/openedx-filters/pull/336/changes

Notably:

class CoursewareAccessChecksRequested(OpenEdxPublicFilter):
   [...]

    class PreventCoursewareAccess(OpenEdxFilterException):
        [...]

        def __init__(
            self,
            error_code: str,
            developer_message: str,
            user_message: str,
        ) -> None:
            super().__init__()
            self.error_code = error_code
            self.developer_message = developer_message
            self.user_message = user_message

) -> tuple[Any, CourseKey | None, bool | None]:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Simplify the output type to remove ambiguity---None values should not be allowed.

Suggested change
def run_filter(
cls,
user: Any,
course_key: CourseKey,
is_eligible: bool,
) -> tuple[Any, CourseKey | None, bool | None]:
def run_filter(
cls,
user: AbstractBaseUser,
course_key: CourseKey,
is_eligible: bool,
) -> tuple[AbstractBaseUser, CourseKey, bool]:

Copy link
Copy Markdown
Contributor Author

@pwnage101 pwnage101 May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also my comment on an older thread about AbstractBaseUser: #335 (comment)

"""
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.
is_eligible (bool): the current eligibility status before plugin evaluation.

Returns:
tuple[User, CourseKey, bool]:
- User: the Django User object (unchanged).
- CourseKey: the course key (unchanged).
- bool: the (possibly overridden) eligibility flag.
"""
data = super().run_pipeline(user=user, course_key=course_key, is_eligible=is_eligible)
return data["user"], data["course_key"], data["is_eligible"]
25 changes: 25 additions & 0 deletions openedx_filters/learning/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
CourseRunAPIRenderStarted,
CourseUnenrollmentStarted,
DashboardRenderStarted,
DiscountEligibilityCheckRequested,
GradeEventContextRequested,
IDVPageURLRequested,
InstructorDashboardRenderStarted,
Expand Down Expand Up @@ -962,3 +963,27 @@ def test_prevent_tabs_generation_exception(self, exception_class, attributes):
exception = exception_class(**attributes)

self.assertLessEqual(attributes.items(), exception.__dict__.items())


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_returns_false_when_pipeline_sets_ineligible(self):
user = Mock()
course_key = Mock()

returned_user, returned_course_key, is_eligible = (
DiscountEligibilityCheckRequested.run_filter(user, course_key, True)
)

self.assertTrue(is_eligible)
self.assertIs(returned_user, user)
self.assertIs(returned_course_key, course_key)
Comment on lines +979 to +989
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the minimal-test convention from #334, the canonical test for a new filter in this repo verifies inputs pass through unchanged when no pipeline is configured — mirror test_schedule_requested directly above. The override-to-False case exercises pipeline-step behavior and belongs alongside the pipeline-step implementation in edx-enterprise, not here.