Skip to content

Commit 7e4eb74

Browse files
committed
Merge user entries on Login
This allows e.g. testing signup/login goals properly.
1 parent 6ac532b commit 7e4eb74

File tree

7 files changed

+221
-36
lines changed

7 files changed

+221
-36
lines changed

experiments/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1+
from django.contrib.auth.signals import user_logged_in
2+
from experiments.signals import transfer_enrollments_to_user
3+
14
from experiments.utils import _record_goal as record_goal
5+
6+
user_logged_in.connect(transfer_enrollments_to_user, dispatch_uid="experiments_user_logged_in")

experiments/counters.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,33 @@
1313
COUNTER_CACHE_KEY = 'experiments:participants:%s'
1414
COUNTER_FREQ_CACHE_KEY = 'experiments:freq:%s'
1515

16-
def increment(key, participant_identifier):
16+
def increment(key, participant_identifier, count=1):
17+
if count == 0:
18+
return
19+
1720
try:
1821
cache_key = COUNTER_CACHE_KEY % key
1922
freq_cache_key = COUNTER_FREQ_CACHE_KEY % key
20-
new_value = r.hincrby(cache_key, participant_identifier, 1)
23+
new_value = r.hincrby(cache_key, participant_identifier, count)
2124

2225
# Maintain histogram of per-user counts
23-
if new_value > 1:
24-
r.hincrby(freq_cache_key, new_value - 1, -1)
26+
if new_value > count:
27+
r.hincrby(freq_cache_key, new_value - count, -1)
2528
r.hincrby(freq_cache_key, new_value, 1)
2629
except (ConnectionError, ResponseError):
2730
# Handle Redis failures gracefully
2831
pass
2932

33+
def clear(key, participant_identifier):
34+
# Remove the direct entry
35+
cache_key = COUNTER_CACHE_KEY % key
36+
pipe = r.pipeline()
37+
freq, _ = pipe.hget(key, participant_identifier).hdel(cache_key, participant_identifier).execute()
38+
39+
# Remove from the histogram
40+
freq_cache_key = COUNTER_FREQ_CACHE_KEY % key
41+
r.hincrby(freq_cache_key, freq, -1)
42+
3043
def get(key):
3144
try:
3245
cache_key = COUNTER_CACHE_KEY % key
@@ -35,6 +48,14 @@ def get(key):
3548
# Handle Redis failures gracefully
3649
return 0
3750

51+
def get_frequency(key, participant_identifier):
52+
try:
53+
cache_key = COUNTER_CACHE_KEY % key
54+
freq = r.hget(cache_key, participant_identifier)
55+
return int(freq) if freq else 0
56+
except (ConnectionError, ResponseError):
57+
# Handle Redis failures gracefully
58+
return 0
3859

3960
def get_frequencies(key):
4061
try:

experiments/models.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,33 @@ def increment_participant_count(self, alternative_name, participant_identifier):
8484
counter_key = PARTICIPANT_KEY % (self.name, alternative_name)
8585
counters.increment(counter_key, participant_identifier)
8686

87-
def increment_goal_count(self, alternative_name, goal_name, participant_identifier):
87+
def increment_goal_count(self, alternative_name, goal_name, participant_identifier, count=1):
8888
# Increment experiment_name:alternative:participant counter
8989
counter_key = GOAL_KEY % (self.name, alternative_name, goal_name)
90-
counters.increment(counter_key, participant_identifier)
90+
counters.increment(counter_key, participant_identifier, count)
91+
92+
def remove_participant(self, alternative_name, participant_identifier):
93+
# Remove participation record
94+
counter_key = PARTICIPANT_KEY % (self.name, alternative_name)
95+
counters.clear(counter_key, participant_identifier)
96+
97+
# Remove goal records
98+
goal_names = getattr(settings, 'EXPERIMENTS_GOALS', [])
99+
for goal_name in goal_names:
100+
counter_key = GOAL_KEY % (self.name, alternative_name, goal_name)
101+
counters.clear(counter_key, participant_identifier)
91102

92103
def participant_count(self, alternative):
93104
return counters.get(PARTICIPANT_KEY % (self.name, alternative))
94105

95106
def goal_count(self, alternative, goal):
96107
return counters.get(GOAL_KEY % (self.name, alternative, goal))
97108

109+
def participant_goal_frequencies(self, alternative, participant_identifier):
110+
goal_names = getattr(settings, 'EXPERIMENTS_GOALS', [])
111+
for goal in goal_names:
112+
yield goal, counters.get_frequency(GOAL_KEY % (self.name, alternative, goal), participant_identifier)
113+
98114
def goal_distribution(self, alternative, goal):
99115
return counters.get_frequencies(GOAL_KEY % (self.name, alternative, goal))
100116

experiments/signals.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import absolute_import
2+
3+
from experiments.utils import create_user
4+
5+
def transfer_enrollments_to_user(sender, request, user, **kwargs):
6+
anon_user = create_user(session=request.session)
7+
authenticated_user = create_user(user=user)
8+
authenticated_user.incorporate(anon_user)

experiments/tests/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
from .mannwhitney import MannWhitneyTestCase
55
from .counter import CounterTestCase
66
from .webuser import WebUserAnonymousTestCase, WebUserAuthenticatedTestCase, BotTestCase
7+
from . import webuser_incorporate
8+
9+
def load_tests(*args, **kwargs):
10+
return webuser_incorporate.load_tests(*args, **kwargs)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from django.test import TestCase
2+
from django.utils.unittest import TestSuite
3+
from django.contrib.sessions.backends.db import SessionStore as DatabaseSession
4+
from django.contrib.auth.models import User
5+
6+
from experiments.utils import DummyUser, SessionUser, AuthenticatedUser
7+
from experiments.models import Experiment, ENABLED_STATE
8+
9+
class WebUserIncorporateTestCase(object):
10+
def test_can_incorporate(self):
11+
self.incorporating.incorporate(self.incorporated)
12+
13+
def test_incorporates_enrollment_from_other(self):
14+
if not self._has_data():
15+
return
16+
17+
try:
18+
experiment = Experiment.objects.create(name='backgroundcolor', state=ENABLED_STATE)
19+
self.incorporated.set_enrollment(experiment, 'blue')
20+
self.incorporating.incorporate(self.incorporated)
21+
self.assertEqual(self.incorporating.get_enrollment(experiment), 'blue')
22+
finally:
23+
experiment.delete()
24+
25+
def _has_data(self):
26+
return not isinstance(self.incorporated, DummyUser) and not isinstance(self.incorporating, DummyUser)
27+
28+
def dummy(incorporating):
29+
return DummyUser()
30+
31+
def anonymous(incorporating):
32+
return SessionUser(session=DatabaseSession())
33+
34+
def authenticated(incorporating):
35+
return AuthenticatedUser(user=User.objects.create(username=['incorporating_user', 'incorporated_user'][incorporating]))
36+
37+
user_factories = (dummy, anonymous, authenticated)
38+
39+
def load_tests(loader, standard_tests, _):
40+
suite = TestSuite()
41+
suite.addTests(standard_tests)
42+
43+
for incorporating in user_factories:
44+
for incorporated in user_factories:
45+
test_case = build_test_case(incorporating, incorporated)
46+
tests = loader.loadTestsFromTestCase(test_case)
47+
suite.addTests(tests)
48+
return suite
49+
50+
51+
def build_test_case(incorporating, incorporated):
52+
class InstantiatedTestCase(WebUserIncorporateTestCase, TestCase):
53+
54+
def setUp(self):
55+
super(InstantiatedTestCase, self).setUp()
56+
self.incorporating = incorporating(True)
57+
self.incorporated = incorporated(False)
58+
InstantiatedTestCase.__name__ = "WebUserIncorporateTestCase_into_%s_from_%s" % (incorporating.__name__, incorporated.__name__)
59+
return InstantiatedTestCase
60+

experiments/utils.py

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def set_enrollment(self, experiment, alternative):
5454
alternative will be increment, but those for the old one will not be decremented."""
5555
raise NotImplementedError
5656

57-
def record_goal(self, goal_name):
57+
def record_goal(self, goal_name, count=1):
5858
"""Record that this user has performed a particular goal
5959
6060
This will update the goal stats for all experiments the user is enrolled in."""
@@ -85,16 +85,62 @@ def is_enrolled(self, experiment_name, alternative, request):
8585

8686
return alternative == chosen_alternative
8787

88+
def incorporate(self, other_user):
89+
"""Incorporate all enrollments and goals performed by the other user
90+
91+
If this user is not enrolled in a given experiment, the results for the
92+
other user are incorporated. For experiments this user is already
93+
enrolled in the results of the other user are discarded.
94+
95+
This takes a relatively large amount of time for each experiment the other
96+
user is enrolled in."""
97+
for experiment, alternative in other_user._get_all_enrollments():
98+
if not self._is_enrolled_in_experiment(experiment):
99+
self.set_enrollment(experiment, alternative)
100+
goals = experiment.participant_goal_frequencies(alternative, other_user._participant_identifier())
101+
for goal_name, count in goals:
102+
experiment.increment_goal_count(alternative, goal_name, self._participant_identifier(), count)
103+
other_user._cancel_enrollment(experiment)
104+
105+
def _participant_identifier(self):
106+
"Unique identifier for this user in the counter store"
107+
raise NotImplementedError
108+
109+
def _get_all_enrollments(self):
110+
"Return experiment, alternative tuples for all experiments the user is enrolled in"
111+
raise NotImplementedError
112+
113+
def _is_enrolled_in_experiment(self, experiment):
114+
"Test whether the user currently has an enrollment in the supplied experiment"
115+
raise NotImplementedError
116+
117+
def _cancel_enrollment(self, experiment):
118+
"Remove the enrollment and any goals the user has against this experiment"
119+
raise NotImplementedError
120+
88121

89122
class DummyUser(WebUser):
90123
def get_enrollment(self, experiment):
91124
return CONTROL_GROUP
92125
def set_enrollment(self, experiment, alternative):
93126
pass
94-
def record_goal(self, goal_name):
127+
def record_goal(self, goal_name, count=1):
95128
pass
96129
def is_enrolled(self, experiment_name, alternative, request):
97130
return alternative == CONTROL_GROUP
131+
def incorporate(self, other_user):
132+
for experiment, alternative in other_user._get_all_enrollments():
133+
other_user._cancel_enrollment(experiment)
134+
def _participant_identifier(self):
135+
return ""
136+
def _get_all_enrollments(self):
137+
return []
138+
def _is_enrolled_in_experiment(self, experiment):
139+
return False
140+
def _cancel_enrollment(self, experiment):
141+
pass
142+
def _get_goal_counts(self, experiment, alternative):
143+
return {}
98144

99145

100146
class AuthenticatedUser(WebUser):
@@ -120,17 +166,32 @@ def set_enrollment(self, experiment, alternative):
120166
enrollment.save()
121167
experiment.increment_participant_count(alternative, self._participant_identifier())
122168

123-
def record_goal(self, goal_name):
124-
enrollments = Enrollment.objects.filter(user=self.user)
125-
if not enrollments:
126-
return
127-
for enrollment in enrollments: # Looks up by PK so no point caching.
128-
if enrollment.experiment.is_displaying_alternatives():
129-
enrollment.experiment.increment_goal_count(enrollment.alternative, goal_name, self._participant_identifier())
169+
def record_goal(self, goal_name, count=1):
170+
for experiment, alternative in self._get_all_enrollments():
171+
if experiment.is_displaying_alternatives():
172+
experiment.increment_goal_count(alternative, goal_name, self._participant_identifier(), count)
130173

131174
def _participant_identifier(self):
132175
return 'user:%d' % (self.user.pk,)
133176

177+
def _get_all_enrollments(self):
178+
enrollments = Enrollment.objects.filter(user=self.user).select_related("experiment")
179+
if enrollments:
180+
for enrollment in enrollments:
181+
yield enrollment.experiment, enrollment.alternative
182+
183+
def _is_enrolled_in_experiment(self, experiment):
184+
return Enrollment.objects.filter(user=self.user, experiment=experiment).exists()
185+
186+
def _cancel_enrollment(self, experiment):
187+
try:
188+
enrollment = Enrollment.objects.get(user=self.user, experiment=experiment)
189+
except Enrollment.DoesNotExist:
190+
pass
191+
else:
192+
experiment.remove_participant(enrollment.alternative, self._participant_identifier())
193+
enrollment.delete()
194+
134195

135196
class SessionUser(WebUser):
136197
def __init__(self, session):
@@ -151,16 +212,11 @@ def set_enrollment(self, experiment, alternative):
151212
if self._is_verified_human():
152213
experiment.increment_participant_count(alternative, self._participant_identifier())
153214

154-
def record_goal(self, goal_name):
215+
def record_goal(self, goal_name, count=1):
155216
if self._is_verified_human():
156-
enrollments = self.session.get('experiments_enrollments', None)
157-
if not enrollments:
158-
return
159-
for experiment_name, (alternative, goals) in enrollments.items():
160-
experiment = experiment_manager.get(experiment_name, None)
161-
if experiment and experiment.is_displaying_alternatives():
162-
experiment.increment_goal_count(alternative, goal_name, self._participant_identifier())
163-
return
217+
for experiment, alternative in self._get_all_enrollments():
218+
if experiment.is_displaying_alternatives():
219+
experiment.increment_goal_count(alternative, goal_name, self._participant_identifier(), count)
164220
else:
165221
goals = self.session.get('experiments_goals', [])
166222
goals.append(goal_name) # Note, duplicates are allowed
@@ -172,16 +228,9 @@ def confirm_human(self):
172228

173229
self.session['experiments_verified_human'] = True
174230

175-
enrollments = self.session.get('experiments_enrollments', None)
176-
if not enrollments:
177-
return
178-
179231
# Replay enrollments
180-
for experiment_name, data in enrollments.items():
181-
alternative, goals = data
182-
experiment = experiment_manager.get(experiment_name, None)
183-
if experiment:
184-
experiment.increment_participant_count(alternative, self._participant_identifier())
232+
for experiment, alternative in self._get_all_enrollments():
233+
experiment.increment_participant_count(alternative, self._participant_identifier())
185234

186235
# Replay goals
187236
if 'experiments_goals' in self.session:
@@ -190,12 +239,34 @@ def confirm_human(self):
190239
del self.session['experiments_goals']
191240

192241
def _participant_identifier(self):
193-
if not self.session.session_key:
194-
self.session.save() # Force session key
195-
return 'session:%s' % (self.session.session_key,)
242+
if 'experiments_session_key' not in self.session:
243+
if not self.session.session_key:
244+
self.session.save() # Force session key
245+
self.session['experiments_session_key'] = self.session.session_key
246+
return 'session:%s' % (self.session['experiments_session_key'],)
196247

197248
def _is_verified_human(self):
198249
if getattr(settings, 'EXPERIMENTS_VERIFY_HUMAN', True):
199250
return self.session.get('experiments_verified_human', False)
200251
else:
201252
return True
253+
254+
def _get_all_enrollments(self):
255+
enrollments = self.session.get('experiments_enrollments', None)
256+
if enrollments:
257+
for experiment_name, data in enrollments.items():
258+
alternative, _ = data
259+
experiment = experiment_manager.get(experiment_name, None)
260+
if experiment:
261+
yield experiment, alternative
262+
263+
def _is_enrolled_in_experiment(self, experiment):
264+
enrollments = self.session.get('experiments_enrollments', None)
265+
return enrollments and experiment.name in enrollments
266+
267+
def _cancel_enrollment(self, experiment):
268+
alternative = self.get_enrollment(experiment)
269+
if alternative:
270+
experiment.remove_participant(alternative, self._participant_identifier())
271+
enrollments = self.session.get('experiments_enrollments', None)
272+
del enrollments[experiment.name]

0 commit comments

Comments
 (0)