Skip to content

Commit aa1b064

Browse files
Merge pull request #197 from edx/feature/audit-expiry-urgency
feat: [COSMO2-846] Audit Expiry Urgency enrollment-time assignment with persisted expiry
2 parents 5b84c2a + 4a6d251 commit aa1b064

7 files changed

Lines changed: 846 additions & 1 deletion

File tree

lms/djangoapps/experiments/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class ExperimentsConfig(AppConfig):
77
Application Configuration for experiments.
88
"""
99
name = 'lms.djangoapps.experiments'
10+
11+
def ready(self):
12+
# Import signal handlers.
13+
from . import signals # pylint: disable=unused-import, import-outside-toplevel
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
"""Audit Expiry Urgency experiment (v1) helpers.
2+
3+
Implements enrollment-time assignment via Optimizely and persistence of a stable
4+
`audit_expiry_at` datetime via CourseEnrollmentAttribute.
5+
6+
Design constraints (from ticket):
7+
* Experiment key: audit_expiry_urgency_v1
8+
* Variants: control_5_7_weeks, expiry_7_days
9+
* Assignment unit: user_id
10+
* Stickiness: across sessions/devices and across the configured target courses
11+
* Failure behavior: default to control
12+
"""
13+
14+
import logging
15+
from datetime import timedelta
16+
17+
from django.conf import settings
18+
from django.core.exceptions import MultipleObjectsReturned
19+
from django.utils import timezone
20+
21+
from common.djangoapps.course_modes.models import CourseMode
22+
from common.djangoapps.student.models import CourseEnrollmentAttribute
23+
from lms.djangoapps.utils import OptimizelyClient
24+
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
25+
from openedx.features.course_duration_limits.access import get_user_course_duration
26+
27+
from .flags import AUDIT_EXPIRY_URGENCY_V1_ENABLED
28+
29+
log = logging.getLogger(__name__)
30+
31+
# Avoid emitting a warning on every enrollment save if the allowlist is missing.
32+
_WARNED_NO_TARGET_COURSES = False
33+
34+
35+
EXPERIMENT_KEY = 'audit_expiry_urgency_v1'
36+
37+
VARIANT_CONTROL = 'control_5_7_weeks'
38+
VARIANT_EXPIRY_7_DAYS = 'expiry_7_days'
39+
VALID_VARIANTS = {VARIANT_CONTROL, VARIANT_EXPIRY_7_DAYS}
40+
41+
ATTRIBUTE_NAMESPACE = 'audit_expiry_experiment'
42+
ATTRIBUTE_NAME_EXPERIMENT_KEY = 'experiment_key'
43+
ATTRIBUTE_NAME_VARIANT = 'variant'
44+
ATTRIBUTE_NAME_EXPIRY_DAYS = 'expiry_days'
45+
ATTRIBUTE_NAME_ASSIGNED_AT = 'assigned_at'
46+
ATTRIBUTE_NAME_DECISION_SOURCE = 'decision_source'
47+
ATTRIBUTE_NAME_AUDIT_EXPIRY_AT = 'audit_expiry_at'
48+
49+
SITE_CONFIG_KEY_TARGET_COURSES = 'AUDIT_EXPIRY_EXPERIMENT_COURSES'
50+
51+
# TEMP: Local testing fallback.
52+
# When set (e.g. in devstack), this forces a specific variant and skips the
53+
# Optimizely lookup entirely.
54+
FORCE_VARIANT_SETTING = 'AUDIT_EXPIRY_FORCE_VARIANT'
55+
56+
57+
def _get_configured_target_course_id_strings():
58+
"""Return configured target course run IDs as a list of strings."""
59+
default_from_settings = getattr(settings, SITE_CONFIG_KEY_TARGET_COURSES, [])
60+
configured = configuration_helpers.get_value(SITE_CONFIG_KEY_TARGET_COURSES, default=default_from_settings)
61+
62+
if configured is None:
63+
configured = []
64+
if isinstance(configured, str):
65+
configured = [configured]
66+
if not isinstance(configured, list):
67+
configured = []
68+
69+
course_ids = [str(item) for item in configured if item]
70+
if not course_ids:
71+
global _WARNED_NO_TARGET_COURSES # pylint: disable=global-statement
72+
if not _WARNED_NO_TARGET_COURSES:
73+
log.warning('Audit expiry urgency: no target courses configured (key=%s)', SITE_CONFIG_KEY_TARGET_COURSES)
74+
_WARNED_NO_TARGET_COURSES = True
75+
return course_ids
76+
77+
78+
def is_target_course(course_key):
79+
"""Return True if course_key is one of the configured target course runs."""
80+
return str(course_key) in set(_get_configured_target_course_id_strings())
81+
82+
83+
def _get_attribute(enrollment, name):
84+
"""Get the latest CourseEnrollmentAttribute row for our namespace/name."""
85+
return enrollment.attributes.filter(namespace=ATTRIBUTE_NAMESPACE, name=name).order_by('id').last()
86+
87+
88+
def get_persisted_variant(enrollment):
89+
"""Return the persisted experiment variant for an enrollment, if present."""
90+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_VARIANT)
91+
return attr.value if attr else None
92+
93+
94+
def get_persisted_experiment_key(enrollment):
95+
"""Return the persisted experiment key for an enrollment, if present."""
96+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_EXPERIMENT_KEY)
97+
return attr.value if attr else None
98+
99+
100+
def get_persisted_expiry_days(enrollment):
101+
"""Return the persisted expiry-days value as an int, if valid."""
102+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_EXPIRY_DAYS)
103+
if not attr:
104+
return None
105+
try:
106+
return int(attr.value)
107+
except (TypeError, ValueError):
108+
return None
109+
110+
111+
def get_persisted_assigned_at(enrollment):
112+
"""Return the persisted assignment timestamp for an enrollment, if present."""
113+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_ASSIGNED_AT)
114+
return attr.value if attr else None
115+
116+
117+
def get_persisted_decision_source(enrollment):
118+
"""Return how the persisted experiment decision was made, if present."""
119+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_DECISION_SOURCE)
120+
return attr.value if attr else None
121+
122+
123+
def get_persisted_audit_expiry_at(enrollment):
124+
"""Return the persisted audit expiry datetime string for an enrollment, if present."""
125+
attr = _get_attribute(enrollment, ATTRIBUTE_NAME_AUDIT_EXPIRY_AT)
126+
return attr.value if attr else None
127+
128+
129+
def _set_attribute(enrollment, name, value):
130+
"""Set an enrollment attribute, tolerating duplicates."""
131+
try:
132+
CourseEnrollmentAttribute.objects.update_or_create(
133+
enrollment=enrollment,
134+
namespace=ATTRIBUTE_NAMESPACE,
135+
name=name,
136+
defaults={'value': value},
137+
)
138+
except MultipleObjectsReturned:
139+
# Duplicates are possible (no uniqueness constraint). Prefer updating the latest.
140+
existing = CourseEnrollmentAttribute.objects.filter(
141+
enrollment=enrollment,
142+
namespace=ATTRIBUTE_NAMESPACE,
143+
name=name,
144+
).order_by('id').last()
145+
if existing:
146+
existing.value = value
147+
existing.save()
148+
else:
149+
CourseEnrollmentAttribute.objects.create(
150+
enrollment=enrollment,
151+
namespace=ATTRIBUTE_NAMESPACE,
152+
name=name,
153+
value=value,
154+
)
155+
156+
157+
def _find_existing_variant_for_user(user, target_course_id_strings):
158+
"""Reuse learner-level assignment by looking for an existing stored variant."""
159+
if not target_course_id_strings:
160+
return None
161+
162+
# Note: do NOT filter on enrollment.is_active; we want reenrollments to retain assignment.
163+
existing_variant = (
164+
CourseEnrollmentAttribute.objects.filter(
165+
enrollment__user=user,
166+
enrollment__course_id__in=target_course_id_strings,
167+
namespace=ATTRIBUTE_NAMESPACE,
168+
name=ATTRIBUTE_NAME_VARIANT,
169+
)
170+
.order_by('-id')
171+
.values_list('value', flat=True)
172+
.first()
173+
)
174+
175+
return existing_variant if existing_variant in VALID_VARIANTS else None
176+
177+
178+
def _forced_variant_from_settings():
179+
"""Return a forced variant if configured for local testing, else None."""
180+
forced = getattr(settings, FORCE_VARIANT_SETTING, None)
181+
if forced in VALID_VARIANTS:
182+
return forced
183+
if forced is not None:
184+
log.warning(
185+
'Audit expiry urgency: invalid %s=%r; ignoring (expected one of %s)',
186+
FORCE_VARIANT_SETTING,
187+
forced,
188+
sorted(VALID_VARIANTS),
189+
)
190+
return None
191+
192+
193+
def _activate_optimizely_variant(user):
194+
"""Return variant key from Optimizely activate(), or None on failure."""
195+
optimizely_client = OptimizelyClient.get_optimizely_client()
196+
if optimizely_client is None:
197+
return None
198+
try:
199+
variation_key = optimizely_client.activate(EXPERIMENT_KEY, str(user.id))
200+
return variation_key
201+
except Exception: # pylint: disable=broad-except
202+
# Never break enrollment due to Optimizely issues.
203+
log.exception(
204+
'Audit expiry urgency: Optimizely activate failed for user_id=%s experiment=%s',
205+
user.id,
206+
EXPERIMENT_KEY,
207+
)
208+
return None
209+
210+
211+
def choose_variant(user, target_course_id_strings):
212+
"""Choose (or reuse) a learner-level variant and decision source."""
213+
forced_variant = _forced_variant_from_settings()
214+
if forced_variant:
215+
return forced_variant, 'force_variant'
216+
217+
existing_variant = _find_existing_variant_for_user(user, target_course_id_strings)
218+
if existing_variant:
219+
return existing_variant, 'existing'
220+
221+
variation_key = _activate_optimizely_variant(user)
222+
if variation_key in VALID_VARIANTS:
223+
return variation_key, 'optimizely'
224+
225+
if variation_key is not None:
226+
log.warning('Audit expiry urgency: unexpected variation=%s; falling back to control', variation_key)
227+
else:
228+
log.warning('Audit expiry urgency: Optimizely unavailable; falling back to control')
229+
return VARIANT_CONTROL, 'fallback_control'
230+
231+
232+
def _content_availability_date(enrollment):
233+
course_start = getattr(enrollment.course_overview, 'start', None)
234+
if course_start is None:
235+
return enrollment.created
236+
return max(enrollment.created, course_start)
237+
238+
239+
def compute_audit_expiry_at(enrollment, variant, access_duration):
240+
"""Compute the persisted audit expiry datetime for this enrollment."""
241+
content_availability_date = _content_availability_date(enrollment)
242+
if variant == VARIANT_EXPIRY_7_DAYS:
243+
expiry_at = content_availability_date + timedelta(days=7)
244+
return expiry_at, 7
245+
expiry_at = content_availability_date + access_duration
246+
return expiry_at, access_duration.days
247+
248+
249+
def _track_exposure_event(user, course_key, variant, expiry_days, decision_source):
250+
"""Track the first persisted learner assignment for this experiment."""
251+
try:
252+
optimizely_client = OptimizelyClient.get_optimizely_client()
253+
if optimizely_client:
254+
optimizely_client.track(
255+
'audit_expiry_urgency_exposed',
256+
str(user.id),
257+
attributes={
258+
'experiment_key': EXPERIMENT_KEY,
259+
'variant': variant,
260+
'expiry_days': expiry_days,
261+
'course_id': str(course_key),
262+
'decision_source': decision_source,
263+
}
264+
)
265+
except Exception: # pylint: disable=broad-except
266+
log.exception('Audit expiry urgency: failed to track exposure for user_id=%s', user.id)
267+
268+
269+
def maybe_persist_audit_expiry_urgency_attributes(enrollment):
270+
"""Persist variant and audit_expiry_at for an eligible enrollment.
271+
272+
Safe + idempotent: if audit_expiry_at already exists, this is a no-op.
273+
"""
274+
if not AUDIT_EXPIRY_URGENCY_V1_ENABLED.is_enabled():
275+
return
276+
277+
if not enrollment or not getattr(enrollment, 'user_id', None):
278+
log.warning('Audit expiry urgency: skipped (missing enrollment or user)')
279+
return
280+
281+
if not enrollment.course_overview:
282+
log.warning('Audit expiry urgency: skipped (missing course_overview)')
283+
return
284+
285+
if any((
286+
not enrollment.is_active,
287+
enrollment.mode != CourseMode.AUDIT,
288+
not is_target_course(enrollment.course_id),
289+
)):
290+
return
291+
292+
# Idempotency: do not overwrite once set.
293+
if get_persisted_audit_expiry_at(enrollment):
294+
return
295+
296+
access_duration = get_user_course_duration(enrollment.user, enrollment.course_overview)
297+
if access_duration is None:
298+
# Only apply if Course Duration Limits would normally apply.
299+
return
300+
301+
target_course_ids = _get_configured_target_course_id_strings()
302+
variant, decision_source = choose_variant(enrollment.user, target_course_ids)
303+
304+
audit_expiry_at, expiry_days = compute_audit_expiry_at(enrollment, variant, access_duration)
305+
if timezone.is_naive(audit_expiry_at):
306+
audit_expiry_at = timezone.make_aware(audit_expiry_at, timezone=timezone.utc)
307+
308+
assigned_at = timezone.now()
309+
310+
_set_attribute(enrollment, ATTRIBUTE_NAME_EXPERIMENT_KEY, EXPERIMENT_KEY)
311+
_set_attribute(enrollment, ATTRIBUTE_NAME_VARIANT, variant)
312+
_set_attribute(enrollment, ATTRIBUTE_NAME_EXPIRY_DAYS, str(expiry_days))
313+
_set_attribute(enrollment, ATTRIBUTE_NAME_ASSIGNED_AT, assigned_at.isoformat())
314+
_set_attribute(enrollment, ATTRIBUTE_NAME_DECISION_SOURCE, decision_source)
315+
_set_attribute(enrollment, ATTRIBUTE_NAME_AUDIT_EXPIRY_AT, audit_expiry_at.isoformat())
316+
317+
if decision_source != 'existing':
318+
_track_exposure_event(enrollment.user, enrollment.course_id, variant, expiry_days, decision_source)

lms/djangoapps/experiments/flags.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,28 @@
99
import pytz
1010
from crum import get_current_request
1111
from edx_django_utils.cache import RequestCache
12+
from edx_toggles.toggles import WaffleFlag
1213

1314
from common.djangoapps.track import segment
1415
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
1516
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
1617

1718
log = logging.getLogger(__name__)
1819

20+
# .. toggle_name: experiments.audit_expiry_urgency_v1.enabled
21+
# .. toggle_implementation: WaffleFlag
22+
# .. toggle_default: False
23+
# .. toggle_description: Kill switch for the Audit Expiry Urgency (v1) backend experiment.
24+
# .. toggle_use_cases: temporary
25+
# .. toggle_creation_date: 2026-03-25
26+
# .. toggle_target_removal_date: None
27+
# .. toggle_tickets: Audit Expiry Urgency Experiment
28+
# .. toggle_warning: This temporary feature toggle does not have a target removal date.
29+
AUDIT_EXPIRY_URGENCY_V1_ENABLED = WaffleFlag(
30+
'experiments.audit_expiry_urgency_v1.enabled',
31+
__name__,
32+
)
33+
1934

2035
class ExperimentWaffleFlag(CourseWaffleFlag):
2136
"""

0 commit comments

Comments
 (0)