First PR to replace pytz with zoneinfo for UTC handling across codebase. This PR migrates all UTC timezone handling from pytz to Python’s standard library zoneinfo. The pytz library is now deprecated, and its documentation recommends using zoneinfo for all new code. This update modernizes our codebase, removes legacy pytz usage, and ensures compatibility with current best practices for timezone management in Python 3.9+. No functional changes to timezone logic - just a direct replacement for UTC handling. https://github.com/openedx/edx-platform/issues/33980
181 lines
7.5 KiB
Python
181 lines
7.5 KiB
Python
"""Tests of openedx.features.discounts.applicability"""
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import Mock, patch
|
|
|
|
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
|
|
from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
from lms.djangoapps.experiments.models import ExperimentData
|
|
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.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
|
|
|
from ..applicability import DISCOUNT_APPLICABILITY_FLAG, _is_in_holdback_and_bucket, can_receive_discount
|
|
|
|
|
|
@ddt.ddt
|
|
class TestApplicability(ModuleStoreTestCase):
|
|
"""
|
|
Applicability determines if this combination of user and course can receive a discount. Make
|
|
sure that all of the business conditions work.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.site, _ = Site.objects.get_or_create(domain='example.com')
|
|
self.user = UserFactory.create()
|
|
self.course = CourseFactory.create(run='test', display_name='test')
|
|
CourseModeFactory.create(course_id=self.course.id, mode_slug='verified')
|
|
now_time = datetime.now(tz=ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S%z")
|
|
ExperimentData.objects.create(
|
|
user=self.user, experiment_id=REV1008_EXPERIMENT_ID, key=str(self.course.id), value=now_time
|
|
)
|
|
|
|
holdback_patcher = patch(
|
|
'openedx.features.discounts.applicability._is_in_holdback_and_bucket', return_value=False
|
|
)
|
|
self.mock_holdback = holdback_patcher.start()
|
|
self.addCleanup(holdback_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)
|
|
assert applicability is False
|
|
|
|
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
|
|
def test_can_receive_discount_course_requirements(self):
|
|
"""
|
|
Ensure first purchase offer banner only displays for courses with a non-expired verified mode
|
|
"""
|
|
CourseEnrollmentFactory(
|
|
is_active=True,
|
|
course_id=self.course.id,
|
|
user=self.user
|
|
)
|
|
|
|
applicability = can_receive_discount(user=self.user, course=self.course)
|
|
assert applicability is True
|
|
|
|
no_verified_mode_course = CourseFactory(end=now() + timedelta(days=30))
|
|
applicability = can_receive_discount(user=self.user, course=no_verified_mode_course)
|
|
assert applicability is False
|
|
|
|
course_that_has_ended = CourseFactory(end=now() - timedelta(days=30))
|
|
applicability = can_receive_discount(user=self.user, course=course_that_has_ended)
|
|
assert applicability is False
|
|
|
|
disabled_course = CourseFactory()
|
|
CourseModeFactory.create(course_id=disabled_course.id, mode_slug='verified') # lint-amnesty, pylint: disable=no-member
|
|
disabled_course_overview = CourseOverview.get_from_id(disabled_course.id) # lint-amnesty, pylint: disable=no-member
|
|
DiscountRestrictionConfig.objects.create(disabled=True, course=disabled_course_overview)
|
|
applicability = can_receive_discount(user=self.user, course=disabled_course)
|
|
assert applicability is False
|
|
|
|
@ddt.data(*(
|
|
[[]] +
|
|
[[mode] for mode in CourseMode.ALL_MODES] +
|
|
[
|
|
[mode1, mode2]
|
|
for mode1 in CourseMode.ALL_MODES
|
|
for mode2 in CourseMode.ALL_MODES
|
|
if mode1 != mode2
|
|
]
|
|
))
|
|
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
|
|
def test_can_receive_discount_previous_verified_enrollment(self, existing_enrollments):
|
|
"""
|
|
Ensure that only users who have not already purchased courses receive the discount.
|
|
"""
|
|
CourseEnrollmentFactory(
|
|
is_active=True,
|
|
course_id=self.course.id,
|
|
user=self.user
|
|
)
|
|
|
|
for mode in existing_enrollments:
|
|
CourseEnrollmentFactory.create(mode=mode, user=self.user)
|
|
|
|
applicability = can_receive_discount(user=self.user, course=self.course)
|
|
assert applicability == all(mode in CourseMode.UPSELL_TO_VERIFIED_MODES for mode in existing_enrollments)
|
|
|
|
@ddt.data(
|
|
None,
|
|
CourseMode.VERIFIED,
|
|
CourseMode.PROFESSIONAL,
|
|
)
|
|
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
|
|
def test_can_receive_discount_entitlement(self, entitlement_mode):
|
|
"""
|
|
Ensure that only users who have not already purchased courses receive the discount.
|
|
"""
|
|
CourseEnrollmentFactory(
|
|
is_active=True,
|
|
course_id=self.course.id,
|
|
user=self.user
|
|
)
|
|
|
|
if entitlement_mode is not None:
|
|
CourseEntitlementFactory.create(mode=entitlement_mode, user=self.user)
|
|
|
|
applicability = can_receive_discount(user=self.user, course=self.course)
|
|
assert applicability == (entitlement_mode is None)
|
|
|
|
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
|
|
def test_can_receive_discount_false_enterprise(self):
|
|
"""
|
|
Ensure that enterprise users do not receive the discount.
|
|
"""
|
|
enterprise_customer = EnterpriseCustomer.objects.create(
|
|
name='Test EnterpriseCustomer',
|
|
site=self.site
|
|
)
|
|
EnterpriseCustomerUser.objects.create(
|
|
user_id=self.user.id,
|
|
enterprise_customer=enterprise_customer
|
|
)
|
|
|
|
applicability = can_receive_discount(user=self.user, course=self.course)
|
|
assert applicability is False
|
|
|
|
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
|
|
def test_holdback_denies_discount(self):
|
|
"""
|
|
Ensure that users in the holdback do not receive the discount.
|
|
"""
|
|
self.mock_holdback.return_value = True
|
|
|
|
applicability = can_receive_discount(user=self.user, course=self.course)
|
|
assert not applicability
|
|
|
|
@ddt.data(
|
|
(0, True),
|
|
(1, False),
|
|
)
|
|
@ddt.unpack
|
|
@pytest.mark.skip(reason="fix under work by revenue team")
|
|
def test_holdback_group_ids(self, group_number, in_holdback):
|
|
with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=group_number):
|
|
assert _is_in_holdback_and_bucket(self.user) == in_holdback
|
|
|
|
@pytest.mark.skip(reason="fix under work by revenue team")
|
|
def test_holdback_expiry(self):
|
|
with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=0):
|
|
with patch(
|
|
'openedx.features.discounts.applicability.datetime',
|
|
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)
|