From a4cb48f6458c1ccb50ef171d78ed27c0ce63af5b Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 3 Mar 2026 15:44:09 -0800 Subject: [PATCH 1/6] feat: replace enterprise_support import with DiscountEligibilityCheckRequested filter Removes direct lazy imports of is_enterprise_learner from openedx.features.enterprise_support.utils in applicability.py and replaces them with calls to the DiscountEligibilityCheckRequested openedx-filter. Adds the filter to OPEN_EDX_FILTERS_CONFIG with the DiscountEligibilityStep pipeline step. Updates tests to mock the filter instead of using enterprise model factories. ENT-11564 Co-Authored-By: Claude Sonnet 4.6 --- lms/envs/common.py | 12 ++++++++ openedx/features/discounts/applicability.py | 28 ++++++++++--------- .../discounts/tests/test_applicability.py | 28 +++++++++++-------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 917dd025e96f..004d21cdff89 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3172,3 +3172,15 @@ def _should_send_certificate_events(settings): SSL_AUTH_DN_FORMAT_STRING = ( "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" ) + +# .. setting_name: OPEN_EDX_FILTERS_CONFIG +# .. setting_default: {} +# .. setting_description: Configuration dict for openedx-filters pipeline steps. +# Keys are filter type strings; values are dicts with 'fail_silently' (bool) and +# 'pipeline' (list of dotted-path strings to PipelineStep subclasses). +OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.discount.eligibility.check.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.discounts.DiscountEligibilityStep"], + }, +} diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index c930ae2da878..559158904df3 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -10,24 +10,24 @@ from datetime import datetime, timedelta - from zoneinfo import ZoneInfo + from crum import get_current_request, impersonate from django.conf import settings from django.utils import timezone from django.utils.dateparse import parse_datetime from edx_toggles.toggles import WaffleFlag +from openedx_filters.learning.filters import DiscountEligibilityCheckRequested from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import CourseEntitlement -from lms.djangoapps.courseware.utils import is_mode_upsellable +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.track import segment from lms.djangoapps.courseware.toggles import COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT +from lms.djangoapps.courseware.utils import is_mode_upsellable from lms.djangoapps.experiments.models import ExperimentData from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group from openedx.features.discounts.models import DiscountPercentageConfig, DiscountRestrictionConfig -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.track import segment - # .. toggle_name: discounts.enable_first_purchase_discount_override # .. toggle_implementation: WaffleFlag @@ -125,10 +125,11 @@ def can_show_streak_discount_coupon(user, course): if not is_mode_upsellable(user, enrollment): return False - # We can't import this at Django load time within the openedx tests settings context - from openedx.features.enterprise_support.utils import is_enterprise_learner - # Don't give discount to enterprise users - if is_enterprise_learner(user): + # Allow plugins to mark this user as ineligible for the discount. + _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( + user=user, course_key=course.id, is_eligible=True + ) + if not is_eligible: return False return True @@ -179,10 +180,11 @@ def can_receive_discount(user, course, discount_expiration_date=None): if CourseEntitlement.objects.filter(user=user).exists(): return False - # We can't import this at Django load time within the openedx tests settings context - from openedx.features.enterprise_support.utils import is_enterprise_learner - # Don't give discount to enterprise users - if is_enterprise_learner(user): + # Allow plugins to mark this user as ineligible for the discount. + _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( + user=user, course_key=course.id, is_eligible=True + ) + if not is_eligible: return False # Turn holdback on diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index 472120b38705..ad9e768d1f13 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -3,14 +3,13 @@ from datetime import datetime, timedelta from unittest.mock import Mock, patch +from zoneinfo import ZoneInfo import ddt import pytest -from zoneinfo import ZoneInfo from django.contrib.sites.models import Site from django.utils.timezone import now from edx_toggles.toggles.testutils import override_waffle_flag -from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -20,7 +19,8 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.discounts.models import DiscountRestrictionConfig from openedx.features.discounts.utils import REV1008_EXPERIMENT_ID -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order from ..applicability import DISCOUNT_APPLICABILITY_FLAG, _is_in_holdback_and_bucket, can_receive_discount @@ -50,6 +50,14 @@ def setUp(self): self.mock_holdback = holdback_patcher.start() self.addCleanup(holdback_patcher.stop) + # By default, the filter passes eligibility through unchanged. + discount_filter_patcher = patch( + 'openedx.features.discounts.applicability.DiscountEligibilityCheckRequested.run_filter', + side_effect=lambda user, course_key, is_eligible: (user, course_key, is_eligible), + ) + self.mock_discount_filter = discount_filter_patcher.start() + self.addCleanup(discount_filter_patcher.stop) + def test_can_receive_discount(self): # Right now, no one should be able to receive the discount applicability = can_receive_discount(user=self.user, course=self.course) @@ -134,17 +142,13 @@ def test_can_receive_discount_entitlement(self, entitlement_mode): assert applicability == (entitlement_mode is None) @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) - def test_can_receive_discount_false_enterprise(self): + def test_can_receive_discount_false_when_filter_marks_ineligible(self): """ - Ensure that enterprise users do not receive the discount. + Ensure that when the eligibility filter marks the user as ineligible, + no discount is received. """ - enterprise_customer = EnterpriseCustomer.objects.create( - name='Test EnterpriseCustomer', - site=self.site - ) - EnterpriseCustomerUser.objects.create( - user_id=self.user.id, - enterprise_customer=enterprise_customer + self.mock_discount_filter.side_effect = lambda user, course_key, is_eligible: ( + user, course_key, False ) applicability = can_receive_discount(user=self.user, course=self.course) From d1fb68e760514588db7bd96d83ab414e460d0683 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Wed, 10 Jun 2026 20:00:45 +0000 Subject: [PATCH 2/6] fix: correct filter name to DiscountEligibilityEnterpriseStep --- lms/envs/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index d6f4ff46ee24..6f31f5c76ec2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3302,6 +3302,6 @@ def _should_send_certificate_events(settings): OPEN_EDX_FILTERS_CONFIG = { "org.openedx.learning.discount.eligibility.check.requested.v1": { "fail_silently": True, - "pipeline": ["enterprise.filters.discounts.DiscountEligibilityStep"], + "pipeline": ["enterprise.filters.discounts.DiscountEligibilityEnterpriseStep"], }, } From 8f424df9f32c1e6ff24727630275a539c9a79f8f Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Tue, 16 Jun 2026 19:02:41 +0000 Subject: [PATCH 3/6] feat: handle DiscountIneligible exception --- openedx/features/discounts/applicability.py | 24 +++++++++----- .../discounts/tests/test_applicability.py | 32 +++++++++++++++---- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 559158904df3..7556bfc3a3e6 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -9,6 +9,8 @@ """ +import logging + from datetime import datetime, timedelta from zoneinfo import ZoneInfo @@ -29,6 +31,8 @@ from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group from openedx.features.discounts.models import DiscountPercentageConfig, DiscountRestrictionConfig +log = logging.getLogger(__name__) + # .. toggle_name: discounts.enable_first_purchase_discount_override # .. toggle_implementation: WaffleFlag # .. toggle_default: False @@ -126,10 +130,12 @@ def can_show_streak_discount_coupon(user, course): return False # Allow plugins to mark this user as ineligible for the discount. - _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( - user=user, course_key=course.id, is_eligible=True - ) - if not is_eligible: + try: + DiscountEligibilityCheckRequested.run_filter( + user=user, course_key=course.id, is_eligible=True + ) + except DiscountEligibilityCheckRequested.DiscountIneligible as exc: + log.info("User is ineligible for streak discount: %s", exc.message) return False return True @@ -181,10 +187,12 @@ def can_receive_discount(user, course, discount_expiration_date=None): return False # Allow plugins to mark this user as ineligible for the discount. - _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( - user=user, course_key=course.id, is_eligible=True - ) - if not is_eligible: + try: + DiscountEligibilityCheckRequested.run_filter( + user=user, course_key=course.id, is_eligible=True + ) + except DiscountEligibilityCheckRequested.DiscountIneligible as exc: + log.info("User is ineligible for discount: %s", exc.message) return False # Turn holdback on diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index bc758d9228cf..849e4335f88d 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -10,11 +10,13 @@ from django.contrib.sites.models import Site from django.utils.timezone import now from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_filters.learning.filters import DiscountEligibilityCheckRequested from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.courseware.toggles import COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT from lms.djangoapps.experiments.models import ExperimentData from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.discounts.models import DiscountRestrictionConfig @@ -23,7 +25,12 @@ ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -from ..applicability import DISCOUNT_APPLICABILITY_FLAG, _is_in_holdback_and_bucket, can_receive_discount +from ..applicability import ( + DISCOUNT_APPLICABILITY_FLAG, + _is_in_holdback_and_bucket, + can_receive_discount, + can_show_streak_discount_coupon +) @ddt.ddt @@ -50,10 +57,9 @@ def setUp(self): self.mock_holdback = holdback_patcher.start() self.addCleanup(holdback_patcher.stop) - # By default, the filter passes eligibility through unchanged. + # By default, the filter does not raise, meaning the user is eligible. discount_filter_patcher = patch( 'openedx.features.discounts.applicability.DiscountEligibilityCheckRequested.run_filter', - side_effect=lambda user, course_key, is_eligible: (user, course_key, is_eligible), ) self.mock_discount_filter = discount_filter_patcher.start() self.addCleanup(discount_filter_patcher.stop) @@ -144,11 +150,11 @@ def test_can_receive_discount_entitlement(self, entitlement_mode): @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) def test_can_receive_discount_false_when_filter_marks_ineligible(self): """ - Ensure that when the eligibility filter marks the user as ineligible, + Ensure that when the eligibility filter raises DiscountIneligible, no discount is received. """ - self.mock_discount_filter.side_effect = lambda user, course_key, is_eligible: ( - user, course_key, False + self.mock_discount_filter.side_effect = DiscountEligibilityCheckRequested.DiscountIneligible( + message="test: user is ineligible for discount" ) applicability = can_receive_discount(user=self.user, course=self.course) @@ -182,3 +188,17 @@ def test_holdback_expiry(self): Mock(now=Mock(return_value=datetime(2020, 8, 1, 0, 1, tzinfo=ZoneInfo("UTC"))), wraps=datetime), ): assert not _is_in_holdback_and_bucket(self.user) + + @override_waffle_flag(COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT, active=True) + def test_can_show_streak_discount_coupon_false_when_filter_marks_ineligible(self): + """ + Ensure that when the eligibility filter raises DiscountIneligible, + the streak discount coupon is not shown. + """ + CourseEnrollmentFactory(is_active=True, course_id=self.course.id, user=self.user) + self.mock_discount_filter.side_effect = DiscountEligibilityCheckRequested.DiscountIneligible( + message="test: user is ineligible for streak discount" + ) + + applicability = can_show_streak_discount_coupon(user=self.user, course=self.course) + assert applicability is False From 17e7be14de22fad8af9bfebe5292d162fc76fbee Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Thu, 25 Jun 2026 13:37:01 +0000 Subject: [PATCH 4/6] fix: remove DiscountEligibilityCheckRequested unused arg is_eligible --- openedx/features/discounts/applicability.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 7556bfc3a3e6..d6eadfee0800 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -132,7 +132,7 @@ def can_show_streak_discount_coupon(user, course): # Allow plugins to mark this user as ineligible for the discount. try: DiscountEligibilityCheckRequested.run_filter( - user=user, course_key=course.id, is_eligible=True + user=user, course_key=course.id, ) except DiscountEligibilityCheckRequested.DiscountIneligible as exc: log.info("User is ineligible for streak discount: %s", exc.message) @@ -189,7 +189,7 @@ def can_receive_discount(user, course, discount_expiration_date=None): # Allow plugins to mark this user as ineligible for the discount. try: DiscountEligibilityCheckRequested.run_filter( - user=user, course_key=course.id, is_eligible=True + user=user, course_key=course.id, ) except DiscountEligibilityCheckRequested.DiscountIneligible as exc: log.info("User is ineligible for discount: %s", exc.message) From 760017d35522654651202eb2156727e0ceb6d1d3 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Mon, 29 Jun 2026 15:38:00 +0000 Subject: [PATCH 5/6] chore: upgrade openedx-filters and edx-enterprise --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 4 ++-- requirements/edx/development.txt | 4 ++-- requirements/edx/doc.txt | 4 ++-- requirements/edx/testing.txt | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a622294e90d9..2a041ca12507 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -44,7 +44,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==8.0.15 +edx-enterprise==8.2.0 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0abef61920bd..419634f0fa8c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -483,7 +483,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.0.15 +edx-enterprise==8.2.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in @@ -861,7 +861,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.4.1 +openedx-filters==3.7.0 # via # -r requirements/edx/kernel.in # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4b3ce79fba60..5b17a9b4b90e 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -756,7 +756,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.0.15 +edx-enterprise==8.2.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt @@ -1413,7 +1413,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.4.1 +openedx-filters==3.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index fa38d1b50fed..9e19242d5f7f 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -573,7 +573,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.0.15 +edx-enterprise==8.2.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -1041,7 +1041,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.4.1 +openedx-filters==3.7.0 # via # -r requirements/edx/base.txt # edx-enterprise diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 40d1be288c78..c071c323fcef 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -589,7 +589,7 @@ edx-drf-extensions==10.6.0 # enterprise-integrated-channels # openedx-authz # openedx-core -edx-enterprise==8.0.15 +edx-enterprise==8.2.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -1081,7 +1081,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.4.1 +openedx-filters==3.7.0 # via # -r requirements/edx/base.txt # edx-enterprise From ebc3ead15e9e80d89e8b2e8cc6cb79f18b761094 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Mon, 29 Jun 2026 18:27:39 +0000 Subject: [PATCH 6/6] chore: quality fixes --- openedx/features/discounts/applicability.py | 1 - openedx/features/discounts/tests/test_applicability.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index d6eadfee0800..e7b348116e4c 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -10,7 +10,6 @@ import logging - from datetime import datetime, timedelta from zoneinfo import ZoneInfo diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index 849e4335f88d..e60ad57d46cd 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -21,15 +21,16 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.discounts.models import DiscountRestrictionConfig from openedx.features.discounts.utils import REV1008_EXPERIMENT_ID -from xmodule.modulestore.tests.django_utils import \ - ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # lint-amnesty, pylint: disable=wrong-import-order +) from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order from ..applicability import ( DISCOUNT_APPLICABILITY_FLAG, _is_in_holdback_and_bucket, can_receive_discount, - can_show_streak_discount_coupon + can_show_streak_discount_coupon, )