Files
edx-platform/lms/djangoapps/experiments/flags.py
Eugene Dyudyunov 655e4a344f refactor!: update CourseWaffleFlag (#30351)
BREAKING: get rid of the LegacyWaffle-based CourseWaffleFlag.
Both CourseWaffleFlag and FutureCourseWaffleFlag now use the modern
WaffleFlag as parent class. FutureCourseWaffleFlag left to support ORA
transition to modern waffle.

Switch to the ORA version which supporting new Waffles.
2022-05-10 15:08:59 -04:00

289 lines
12 KiB
Python

"""
Feature flag support for experiments
"""
import datetime
import logging
import dateutil
import pytz
from crum import get_current_request
from edx_django_utils.cache import RequestCache
from common.djangoapps.track import segment
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
log = logging.getLogger(__name__)
class ExperimentWaffleFlag(CourseWaffleFlag):
"""
ExperimentWaffleFlag handles logic around experimental bucketing and whitelisting.
You'll have one main flag that gates the experiment. This allows you to control the scope
of your experiment and always provides a quick kill switch.
But you'll also have smaller related flags that can force bucketing certain users into
specific buckets of your experiment. Those can be set using a waffle named like
"main_flag.BUCKET_NUM" (e.g. "course_experience.animated_exy.0") to force
users that pass the first main waffle check into a specific bucket experience.
If a user is not forced into a specific bucket by one of the aforementioned smaller flags,
then they will be randomly assigned a default bucket based on a consistent hash of:
* (flag_name, course_key, username) if use_course_aware_bucketing=True, or
* (flag_name, username) if use_course_aware_bucketing=False.
Note that you may call `.get_bucket` and `.is_enabled` without a course_key, in which case:
* the smaller flags will be evaluated without course context, and
* the default bucket will be evaluated as if use_course_aware_bucketing=False.
You can also control whether the experiment only affects future enrollments by setting
an ExperimentKeyValue model object with a key of 'enrollment_start' to the date of the
first enrollments that should be bucketed.
Bucket 0 is assumed to be the control bucket.
See a HOWTO here: https://openedx.atlassian.net/wiki/spaces/AC/pages/1250623700/Bucketing+users+for+an+experiment
When writing tests involving an ExperimentWaffleFlag you must not use the
override_waffle_flag utility. That will only turn the experiment on or off and won't
override bucketing. Instead use override_experiment_waffle_flag function which
will do both. Example:
from lms.djangoapps.experiments.testutils import override_experiment_waffle_flag
with @override_experiment_waffle_flag(MY_EXPERIMENT_WAFFLE_FLAG, active=True, bucket=1):
...
or as a decorator:
@override_experiment_waffle_flag(MY_EXPERIMENT_WAFFLE_FLAG, active=True, bucket=1)
def test_my_experiment(self):
...
"""
def __init__(
self,
flag_name,
module_name,
num_buckets=2,
experiment_id=None,
use_course_aware_bucketing=True,
**kwargs
):
super().__init__(flag_name, module_name, **kwargs)
self.num_buckets = num_buckets
self.experiment_id = experiment_id
self.bucket_flags = [
CourseWaffleFlag(f'{flag_name}.{bucket}', module_name) # lint-amnesty, pylint: disable=toggle-missing-annotation
for bucket in range(num_buckets)
]
self.use_course_aware_bucketing = use_course_aware_bucketing
@property
def _app_label(self):
"""
By convention, the app label associated to an experiment waffle flag is the dotted prefix of the flag name. For
example: if the flag name is "grades.my.experiment.waffle.flag", then the `_app_label` will be "grades".
This app label replaces what was formerly known as the waffle flag namespace.
"""
return self._split_name[0]
@property
def _experiment_name(self):
"""
By convention, the app label associated to an experiment waffle flag is the first dotted suffix of the flag
name. For example: if the flag name name is "grades.my.experiment.waffle.flag", then the `_experiment_name`
will be "my.experiment.waffle.flag".
"""
return self._split_name[1]
@property
def _split_name(self):
"""
Return the flag name prefix (before the first dot) and suffix. This raises a ValueError if the flag does not
contain a dot ".".
"""
prefix, suffix = self.name.split(".", maxsplit=1)
return prefix, suffix
def _cache_bucket(self, key, value):
request_cache = RequestCache('experiments')
request_cache.set(key, value)
return value
def _is_enrollment_inside_date_bounds(self, experiment_values, user, course_key):
""" Returns True if the user's enrollment (if any) is valid for the configured experiment date range """
from common.djangoapps.student.models import CourseEnrollment
enrollment_start = experiment_values.get('enrollment_start')
enrollment_end = experiment_values.get('enrollment_end')
if not enrollment_start and not enrollment_end:
return True # early exit just to avoid any further lookups
now = datetime.datetime.now(pytz.utc)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
# If the user isn't enrolled, act like they would enroll right now (this keeps the pre-enroll and post-enroll
# experiences the same, if they decide to enroll right now)
enrollment_creation_date = enrollment.created if enrollment else now
# Enrollment must be after any enrollment_start date, if specified
if enrollment_start:
try:
start_date = dateutil.parser.parse(enrollment_start).replace(tzinfo=pytz.UTC)
except ValueError:
log.exception('Could not parse enrollment start date for experiment %d', self.experiment_id)
return False
if enrollment_creation_date < start_date:
return False
# Enrollment must be before any enrollment_end date, if specified
if enrollment_end:
try:
end_date = dateutil.parser.parse(enrollment_end).replace(tzinfo=pytz.UTC)
except ValueError:
log.exception('Could not parse enrollment end date for experiment %d', self.experiment_id)
return False
if enrollment_creation_date >= end_date:
return False
# All good! Either because the key was not set or because the enrollment was valid
return True
def get_bucket(self, course_key=None, track=True):
"""
Return which bucket number the specified user is in.
The user may be force-bucketed if matching subordinate flags of the form
"main_flag.BUCKET_NUM" exist. Otherwise, they will be hashed into a default
bucket based on their username, the experiment name, and the course-run key.
If `self.use_course_aware_bucketing` is False, the course-run key will
be omitted from the hashing formula, thus making it so a given user
has the same default bucket across all course runs; however, subordinate
flags that match the course-run key will still apply.
If `course_key` argument is omitted altogether, then subordinate flags
will be evaluated outside of the course-run context, and the default bucket
will be calculated as if `self.use_course_aware_bucketing` is False.
Finally, Bucket 0 is assumed to be the control bucket and will be returned if the
experiment is not enabled for this user and course.
Arguments:
course_key (Optional[CourseKey])
This argument should always be passed in a course-aware context even if
course aware bucketing is False.
track (bool):
Whether an analytics event should be generated if the user is
bucketed for the first time.
Returns: int
"""
# Keep some imports in here, because this class is commonly used at a module level, and we want to avoid
# circular imports for any models.
from lms.djangoapps.experiments.models import ExperimentKeyValue
from lms.djangoapps.courseware.masquerade import get_specific_masquerading_user
request = get_current_request()
if not request:
return 0
if hasattr(request, 'user'):
user = get_specific_masquerading_user(request.user, course_key)
if user is None:
user = request.user
masquerading_as_specific_student = False
else:
masquerading_as_specific_student = True
# If a course key is passed in, include it in the experiment name
# in order to separate caches and analytics calls per course-run.
# If we are using course-aware bucketing, then also append that course key
# to `bucketing_group_name`, such that users can be hashed into different
# buckets for different course-runs.
experiment_name = bucketing_group_name = self.name
if course_key:
experiment_name += f".{course_key}"
if course_key and self.use_course_aware_bucketing:
bucketing_group_name += f".{course_key}"
# Check if we have a cache for this request already
request_cache = RequestCache('experiments')
cache_response = request_cache.get_cached_response(experiment_name)
if cache_response.is_found:
return cache_response.value
# Check if the main flag is even enabled for this user and course.
if not self.is_experiment_on(course_key): # grabs user from the current request, if any
return self._cache_bucket(experiment_name, 0)
# Check if the enrollment should even be considered (if it started before the experiment wants, we ignore)
if course_key and self.experiment_id is not None:
values = ExperimentKeyValue.objects.filter(experiment_id=self.experiment_id).values('key', 'value')
values = {pair['key']: pair['value'] for pair in values}
if not self._is_enrollment_inside_date_bounds(values, user, course_key):
return self._cache_bucket(experiment_name, 0)
# Determine the user's bucket.
# First check if forced into a particular bucket, using our subordinate bucket flags.
# If not, calculate their default bucket using a consistent hash function.
for i, bucket_flag in enumerate(self.bucket_flags):
if bucket_flag.is_enabled(course_key):
bucket = i
break
else:
bucket = stable_bucketing_hash_group(
bucketing_group_name, self.num_buckets, user
)
session_key = f'tracked.{experiment_name}'
anonymous = not hasattr(request, 'user') or not request.user.id
if (
track and hasattr(request, 'session') and
session_key not in request.session and
not masquerading_as_specific_student and not anonymous
):
segment.track(
user_id=user.id,
event_name='edx.bi.experiment.user.bucketed',
properties={
'site': request.site.domain,
'app_label': self._app_label,
'experiment': self._experiment_name,
'course_id': str(course_key) if course_key else None,
'bucket': bucket,
'is_staff': user.is_staff,
'nonInteraction': 1,
}
)
# Mark that we've recorded this bucketing, so that we don't do it again this session
request.session[session_key] = True
return self._cache_bucket(experiment_name, bucket)
def is_enabled(self, course_key=None):
"""
Return whether the requesting user is in a nonzero bucket for the given course.
See the docstring of `.get_bucket` for more details.
Arguments:
course_key (Optional[CourseKey])
Returns: bool
"""
return self.get_bucket(course_key) != 0
def is_experiment_on(self, course_key=None):
"""
Return whether the overall experiment flag is enabled for this user.
This disregards `.bucket_flags`.
"""
return super().is_enabled(course_key)