Merge pull request #20936 from cpennington/discount-holdback
Discount holdback
This commit is contained in:
27
lms/djangoapps/experiments/stable_bucketing.py
Normal file
27
lms/djangoapps/experiments/stable_bucketing.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
An implementation of a stable bucketing algorithm that can be used
|
||||
to reliably group users into experiments.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
def stable_bucketing_hash_group(group_name, group_count, username):
|
||||
"""
|
||||
Return the bucket that a user should be in for a given stable bucketing assignment.
|
||||
|
||||
This function has been verified to return the same values as the stable bucketing
|
||||
functions in javascript and the master experiments table.
|
||||
|
||||
Arguments:
|
||||
group_name: The name of the grouping/experiment.
|
||||
group_count: How many groups to bucket users into.
|
||||
username: The username of the user being bucketed.
|
||||
"""
|
||||
hasher = hashlib.md5()
|
||||
hasher.update(group_name.encode('utf-8'))
|
||||
hasher.update(username.encode('utf-8'))
|
||||
hash_str = hasher.hexdigest()
|
||||
|
||||
return int(re.sub('[8-9a-f]', '1', re.sub('[0-7]', '0', hash_str)), 2) % group_count
|
||||
@@ -4,9 +4,7 @@ Utilities to facilitate experimentation
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import six
|
||||
@@ -27,6 +25,9 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitCo
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.partitions.partitions_service import get_all_partitions_for_course, get_user_partition_groups
|
||||
|
||||
# Import this for backwards compatibility (so that anyone importing this function from here doesn't break)
|
||||
from .stable_bucketing import stable_bucketing_hash_group # pylint: disable=unused-import
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -386,23 +387,3 @@ def get_program_context(course, user_enrollments):
|
||||
}
|
||||
return program_key
|
||||
# TODO: clean up as part of REVEM-199 (START)
|
||||
|
||||
|
||||
def stable_bucketing_hash_group(group_name, group_count, username):
|
||||
"""
|
||||
Return the bucket that a user should be in for a given stable bucketing assignment.
|
||||
|
||||
This function has been verified to return the same values as the stable bucketing
|
||||
functions in javascript and the master experiments table.
|
||||
|
||||
Arguments:
|
||||
group_name: The name of the grouping/experiment.
|
||||
group_count: How many groups to bucket users into.
|
||||
username: The username of the user being bucketed.
|
||||
"""
|
||||
hasher = hashlib.md5()
|
||||
hasher.update(group_name.encode('utf-8'))
|
||||
hasher.update(username.encode('utf-8'))
|
||||
hash_str = hasher.hexdigest()
|
||||
|
||||
return int(re.sub('[8-9a-f]', '1', re.sub('[0-7]', '0', hash_str)), 2) % group_count
|
||||
|
||||
@@ -15,7 +15,7 @@ from eventtracking import tracker
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.date_summary import verified_upgrade_deadline_link
|
||||
from lms.djangoapps.experiments.utils import stable_bucketing_hash_group
|
||||
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_run_details
|
||||
from openedx.core.djangoapps.schedules.resolvers import (
|
||||
BinnedSchedulesBaseResolver,
|
||||
|
||||
@@ -8,11 +8,18 @@ Keep in mind that the code in this file only applies to discounts controlled in
|
||||
not other discounts like coupons or enterprise/program offers configured in ecommerce.
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import crum
|
||||
import pytz
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from entitlements.models import CourseEntitlement
|
||||
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace
|
||||
from openedx.features.discounts.models import DiscountRestrictionConfig
|
||||
from student.models import CourseEnrollment
|
||||
from track import segment
|
||||
|
||||
# .. feature_toggle_name: discounts.enable_discounting
|
||||
# .. feature_toggle_type: flag
|
||||
@@ -31,6 +38,8 @@ DISCOUNT_APPLICABILITY_FLAG = WaffleFlag(
|
||||
flag_undefined_default=False
|
||||
)
|
||||
|
||||
DISCOUNT_APPLICABILITY_HOLDBACK = 'first_purchase_discount_holdback'
|
||||
|
||||
|
||||
def can_receive_discount(user, course): # pylint: disable=unused-argument
|
||||
"""
|
||||
@@ -66,9 +75,48 @@ def can_receive_discount(user, course): # pylint: disable=unused-argument
|
||||
if CourseEntitlement.objects.filter(user=user).exists():
|
||||
return False
|
||||
|
||||
# Excute holdback
|
||||
if _is_in_holdback(user):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _is_in_holdback(user):
|
||||
"""
|
||||
Return whether the specified user is in the first-purchase-discount holdback group.
|
||||
"""
|
||||
if datetime(2020, 8, 1, tzinfo=pytz.UTC) <= datetime.now(tz=pytz.UTC):
|
||||
return False
|
||||
|
||||
if not datetime(2019, 8, 1, tzinfo=pytz.UTC) <= user.date_joined <= datetime(2019, 11, 1, tzinfo=pytz.UTC):
|
||||
return False
|
||||
|
||||
# Holdback is 50/50
|
||||
bucket = stable_bucketing_hash_group(DISCOUNT_APPLICABILITY_HOLDBACK, 2, user.username)
|
||||
|
||||
request = crum.get_current_request()
|
||||
if hasattr(request, 'session') and DISCOUNT_APPLICABILITY_HOLDBACK not in request.session:
|
||||
|
||||
properties = {
|
||||
'site': request.site.domain,
|
||||
'app_label': 'discounts',
|
||||
'nonInteraction': 1,
|
||||
'bucket': bucket,
|
||||
'experiment': 'REVEM-363',
|
||||
}
|
||||
segment.track(
|
||||
user_id=user.id,
|
||||
event_name='edx.bi.experiment.user.bucketed',
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
# Mark that we've recorded this bucketing, so that we don't do it again this session
|
||||
request.session[DISCOUNT_APPLICABILITY_HOLDBACK] = True
|
||||
|
||||
return bucket == 0
|
||||
|
||||
|
||||
def discount_percentage():
|
||||
"""
|
||||
Get the configured discount amount.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Tests of openedx.features.discounts.applicability"""
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
import ddt
|
||||
from django.utils.timezone import now
|
||||
from mock import patch, Mock
|
||||
import pytz
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
@@ -15,7 +17,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from ..applicability import can_receive_discount, DISCOUNT_APPLICABILITY_FLAG
|
||||
from ..applicability import can_receive_discount, DISCOUNT_APPLICABILITY_FLAG, _is_in_holdback
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -31,6 +33,10 @@ class TestApplicability(ModuleStoreTestCase):
|
||||
self.course = CourseFactory.create(run='test', display_name='test')
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug='verified')
|
||||
|
||||
holdback_patcher = patch('openedx.features.discounts.applicability._is_in_holdback', 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)
|
||||
@@ -95,3 +101,44 @@ class TestApplicability(ModuleStoreTestCase):
|
||||
|
||||
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_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
|
||||
def test_holdback_group_ids(self, group_number, in_holdback):
|
||||
with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=group_number):
|
||||
with patch.object(self.user, 'date_joined', datetime(2019, 8, 1, 0, 1, tzinfo=pytz.UTC)):
|
||||
assert _is_in_holdback(self.user) == in_holdback
|
||||
|
||||
@ddt.data(
|
||||
(datetime(2019, 7, 31, tzinfo=pytz.UTC), False),
|
||||
(datetime(2019, 8, 1, 0, 1, tzinfo=pytz.UTC), True),
|
||||
(datetime(2019, 10, 30, 23, 59, tzinfo=pytz.UTC), True),
|
||||
(datetime(2019, 11, 1, 0, 1, tzinfo=pytz.UTC), False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_holdback_registration_limits(self, registration_date, in_holdback):
|
||||
with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=0):
|
||||
with patch.object(self.user, 'date_joined', registration_date):
|
||||
assert _is_in_holdback(self.user) == in_holdback
|
||||
|
||||
def test_holdback_expiry(self):
|
||||
with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=0):
|
||||
with patch.object(self.user, 'date_joined', datetime(2019, 8, 1, 0, 1, tzinfo=pytz.UTC)):
|
||||
with patch(
|
||||
'openedx.features.discounts.applicability.datetime',
|
||||
Mock(now=Mock(return_value=datetime(2020, 8, 1, 0, 1, tzinfo=pytz.UTC)))
|
||||
):
|
||||
assert not _is_in_holdback(self.user)
|
||||
|
||||
Reference in New Issue
Block a user