Skip to content
Snippets Groups Projects
Unverified Commit 566cd977 authored by Calen Pennington's avatar Calen Pennington Committed by GitHub
Browse files

Merge pull request #20936 from cpennington/discount-holdback

Discount holdback
parents 3987a17a 708118f9
No related merge requests found
"""
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.
......
"""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)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment