Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
49 changes: 49 additions & 0 deletions openedx_filters/learning/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import Any, Optional

from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models.query import QuerySet
from django.http import HttpResponse, QueryDict
from opaque_keys.edx.keys import CourseKey
Expand Down Expand Up @@ -1598,3 +1599,51 @@ 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: AbstractBaseUser,
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[AbstractBaseUser, CourseKey, bool]:
"""
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.
"""
if is_eligible is False:
# If the user is already marked as ineligible, skip the pipeline to avoid unnecessary processing.
return user, course_key, is_eligible
data = super().run_pipeline(user=user, course_key=course_key, is_eligible=is_eligible)
return data["user"], data["course_key"], data["is_eligible"]
39 changes: 39 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,41 @@ 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)

def test_run_filter_skips_pipeline_when_already_ineligible(self):
user = Mock()
course_key = Mock()

with patch("openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline") as mock_run_pipeline:
returned_user, returned_course_key, is_eligible = (
DiscountEligibilityCheckRequested.run_filter(user, course_key, False)
)

mock_run_pipeline.assert_not_called()
self.assertFalse(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.