143 lines
6.4 KiB
Python
143 lines
6.4 KiB
Python
"""
|
|
Feature flag support for experiments
|
|
"""
|
|
|
|
import logging
|
|
from contextlib import contextmanager
|
|
|
|
import dateutil
|
|
import pytz
|
|
from crum import get_current_request
|
|
from edx_django_utils.cache import RequestCache
|
|
|
|
from experiments.stable_bucketing import stable_bucketing_hash_group
|
|
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
|
from track import segment
|
|
|
|
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 you pass this flag a course key, tracking calls to segment will be made per-course-run
|
|
(rather than one call overall) and will include the course key.
|
|
|
|
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
|
|
"""
|
|
def __init__(self, waffle_namespace, flag_name, num_buckets=2, experiment_id=None, **kwargs):
|
|
super().__init__(waffle_namespace, flag_name, **kwargs)
|
|
self.num_buckets = num_buckets
|
|
self.experiment_id = experiment_id
|
|
self.bucket_flags = [
|
|
CourseWaffleFlag(waffle_namespace, '{}.{}'.format(flag_name, bucket), flag_undefined_default=False)
|
|
for bucket in range(num_buckets)
|
|
]
|
|
|
|
def _cache_bucket(self, key, value):
|
|
request_cache = RequestCache('experiments')
|
|
request_cache.set(key, value)
|
|
return value
|
|
|
|
def get_bucket(self, course_key=None, track=True):
|
|
"""
|
|
Return which bucket number the specified user is in.
|
|
|
|
Bucket 0 is assumed to be the control bucket and will be returned if the experiment is not enabled for
|
|
this user and course.
|
|
"""
|
|
# 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 experiments.models import ExperimentKeyValue
|
|
from student.models import CourseEnrollment
|
|
|
|
request = get_current_request()
|
|
if not request:
|
|
return 0
|
|
|
|
# Use course key in experiment name to separate caches and segment calls per-course-run
|
|
experiment_name = self.namespaced_flag_name + ('.{}'.format(course_key) if course_key else '')
|
|
|
|
# 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_enabled(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:
|
|
start_val = ExperimentKeyValue.objects.filter(experiment_id=self.experiment_id, key='enrollment_start')
|
|
if start_val:
|
|
try:
|
|
start_date = dateutil.parser.parse(start_val.first().value).replace(tzinfo=pytz.UTC)
|
|
except ValueError:
|
|
log.exception('Could not parse enrollment start date for experiment %d', self.experiment_id)
|
|
return self._cache_bucket(experiment_name, 0)
|
|
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
|
|
# Only bail if they have an enrollment and it's old -- if they don't have an enrollment, we want to do
|
|
# normal bucketing -- consider the case where the experiment has bits that show before you enroll. We
|
|
# want to keep your bucketing stable before and after you do enroll.
|
|
if enrollment and enrollment.created < start_date:
|
|
return self._cache_bucket(experiment_name, 0)
|
|
|
|
bucket = stable_bucketing_hash_group(experiment_name, self.num_buckets, request.user.username)
|
|
|
|
# Now check if the user is forced into a particular bucket, using our subordinate bucket flags
|
|
for i, bucket_flag in enumerate(self.bucket_flags):
|
|
if bucket_flag.is_enabled(course_key):
|
|
bucket = i
|
|
break
|
|
|
|
session_key = 'tracked.{}'.format(experiment_name)
|
|
if track and hasattr(request, 'session') and session_key not in request.session:
|
|
segment.track(
|
|
user_id=request.user.id,
|
|
event_name='edx.bi.experiment.user.bucketed',
|
|
properties={
|
|
'site': request.site.domain,
|
|
'app_label': self.waffle_namespace.name,
|
|
'experiment': self.flag_name,
|
|
'course_id': str(course_key) if course_key else None,
|
|
'bucket': bucket,
|
|
'is_staff': request.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 self.get_bucket(course_key) != 0
|
|
|
|
def is_enabled_without_course_context(self):
|
|
return self.is_enabled()
|
|
|
|
@contextmanager
|
|
def override(self, active=True, bucket=1): # pylint: disable=arguments-differ
|
|
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
|
|
with override_waffle_flag(self, active):
|
|
with override_waffle_flag(self.bucket_flags[bucket], True):
|
|
yield
|