diff --git a/lms/djangoapps/experiments/stable_bucketing.py b/lms/djangoapps/experiments/stable_bucketing.py new file mode 100644 index 0000000000..0bc903ee8b --- /dev/null +++ b/lms/djangoapps/experiments/stable_bucketing.py @@ -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 diff --git a/lms/djangoapps/experiments/utils.py b/lms/djangoapps/experiments/utils.py index e921ac7392..3c00f6984c 100644 --- a/lms/djangoapps/experiments/utils.py +++ b/lms/djangoapps/experiments/utils.py @@ -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 diff --git a/openedx/features/course_duration_limits/resolvers.py b/openedx/features/course_duration_limits/resolvers.py index 31c627cac3..d22a36b83e 100644 --- a/openedx/features/course_duration_limits/resolvers.py +++ b/openedx/features/course_duration_limits/resolvers.py @@ -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, diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 0c2bb26ea1..d72e7ec158 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -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. diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index bd5c7d7e01..faa0cadb20 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -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)