Skip to content

Commit 846f096

Browse files
committed
Merge pull request mixcloud#86 from theospears/merge_on_login
Merge experiment users on login
2 parents c5a2c54 + ad1d31f commit 846f096

File tree

11 files changed

+320
-138
lines changed

11 files changed

+320
-138
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import absolute_import
2+
3+
from .stats import StatsTestCase
4+
from .mannwhitney import MannWhitneyTestCase
5+
from .counter import CounterTestCase
6+
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)

experiments/tests/counter.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import absolute_import
2+
3+
from django.utils.unittest import TestCase
4+
5+
from experiments import counters
6+
7+
TEST_KEY = 'CounterTestCase'
8+
9+
class CounterTestCase(TestCase):
10+
def setUp(self):
11+
counters.reset(TEST_KEY)
12+
self.assertEqual(counters.get(TEST_KEY), 0)
13+
14+
def tearDown(self):
15+
counters.reset(TEST_KEY)
16+
17+
def test_add_item(self):
18+
counters.increment(TEST_KEY, 'fred')
19+
self.assertEqual(counters.get(TEST_KEY), 1)
20+
21+
def test_add_multiple_items(self):
22+
counters.increment(TEST_KEY, 'fred')
23+
counters.increment(TEST_KEY, 'barney')
24+
counters.increment(TEST_KEY, 'george')
25+
counters.increment(TEST_KEY, 'george')
26+
self.assertEqual(counters.get(TEST_KEY), 3)
27+
28+
def test_add_duplicate_item(self):
29+
counters.increment(TEST_KEY, 'fred')
30+
counters.increment(TEST_KEY, 'fred')
31+
counters.increment(TEST_KEY, 'fred')
32+
self.assertEqual(counters.get(TEST_KEY), 1)
33+
34+
def test_get_frequencies(self):
35+
counters.increment(TEST_KEY, 'fred')
36+
counters.increment(TEST_KEY, 'barney')
37+
counters.increment(TEST_KEY, 'george')
38+
counters.increment(TEST_KEY, 'roger')
39+
counters.increment(TEST_KEY, 'roger')
40+
counters.increment(TEST_KEY, 'roger')
41+
counters.increment(TEST_KEY, 'roger')
42+
self.assertEqual(counters.get_frequencies(TEST_KEY), {1: 3, 4: 1})
43+
44+
45+
def test_delete_key(self):
46+
counters.increment(TEST_KEY, 'fred')
47+
counters.reset(TEST_KEY)
48+
self.assertEqual(counters.get(TEST_KEY), 0)
49+

experiments/tests/mannwhitney.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.utils.unittest import TestCase
2+
3+
from scipy.stats import mannwhitneyu as scipy_mann_whitney
4+
from experiments.significance import mann_whitney
5+
6+
class MannWhitneyTestCase(TestCase):
7+
def frequencies_to_list(self, frequencies):
8+
entries = []
9+
for entry,count in frequencies.items():
10+
entries.extend([entry] * count)
11+
return entries
12+
13+
def test_empty_sets(self):
14+
mann_whitney(dict(), dict())
15+
16+
def test_identical_ranges(self):
17+
distribution = dict((x,1) for x in range(50))
18+
self.assertMatchesSciPy(distribution, distribution)
19+
20+
def test_many_repeated_values(self):
21+
self.assertMatchesSciPy({0: 100, 1: 50}, {0: 110, 1: 60})
22+
23+
def test_large_range(self):
24+
distribution_a = dict((x,1) for x in range(10000))
25+
distribution_b = dict((x+1,1) for x in range(10000))
26+
self.assertMatchesSciPy(distribution_a, distribution_b)
27+
28+
def test_very_different_sizes(self):
29+
distribution_a = dict((x,1) for x in range(10000))
30+
distribution_b = dict((x,1) for x in range(20))
31+
self.assertMatchesSciPy(distribution_a, distribution_b)
32+
33+
def assertMatchesSciPy(self, distribution_a, distribution_b):
34+
our_u, our_p = mann_whitney(distribution_a, distribution_b)
35+
correct_u, correct_p = scipy_mann_whitney(
36+
self.frequencies_to_list(distribution_a),
37+
self.frequencies_to_list(distribution_b))
38+
self.assertEqual(our_u, correct_u, "U score incorrect")
39+
self.assertAlmostEqual(our_p, correct_p, msg="p value incorrect")

experiments/tests/stats.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.utils.unittest import TestCase
2+
3+
from experiments import stats
4+
5+
class StatsTestCase(TestCase):
6+
def test_flatten(self):
7+
self.assertEqual(
8+
list(stats.flatten([1,[2,[3]],4,5])),
9+
[1,2,3,4,5]
10+
)

experiments/tests.py renamed to experiments/tests/webuser.py

Lines changed: 3 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,17 @@
11
from __future__ import absolute_import
22

3-
from django.utils.unittest import TestCase
3+
from django.test import TestCase
44
from django.test.client import RequestFactory
55
from django.contrib.auth.models import User, AnonymousUser
66
from django.contrib.sessions.backends.db import SessionStore as DatabaseSession
77

8-
from experiments import stats, counters
9-
from experiments.utils import create_user
108
from experiments.models import Experiment, ENABLED_STATE, CONTROL_GROUP
11-
from experiments.significance import mann_whitney
12-
13-
from scipy.stats import mannwhitneyu as scipy_mann_whitney
9+
from experiments.utils import create_user
1410

1511
request_factory = RequestFactory()
16-
TEST_KEY = 'CounterTestCase'
1712
TEST_ALTERNATIVE = 'blue'
1813
TEST_GOAL = 'buy'
1914

20-
class StatsTestCase(TestCase):
21-
def test_flatten(self):
22-
self.assertEqual(
23-
list(stats.flatten([1,[2,[3]],4,5])),
24-
[1,2,3,4,5]
25-
)
26-
27-
28-
class MannWhitneyTestCase(TestCase):
29-
def frequencies_to_list(self, frequencies):
30-
entries = []
31-
for entry,count in frequencies.items():
32-
entries.extend([entry] * count)
33-
return entries
34-
35-
def test_empty_sets(self):
36-
mann_whitney(dict(), dict())
37-
38-
def test_identical_ranges(self):
39-
distribution = dict((x,1) for x in range(50))
40-
self.assertMatchesSciPy(distribution, distribution)
41-
42-
def test_many_repeated_values(self):
43-
self.assertMatchesSciPy({0: 100, 1: 50}, {0: 110, 1: 60})
44-
45-
def test_large_range(self):
46-
distribution_a = dict((x,1) for x in range(10000))
47-
distribution_b = dict((x+1,1) for x in range(10000))
48-
self.assertMatchesSciPy(distribution_a, distribution_b)
49-
50-
def test_very_different_sizes(self):
51-
distribution_a = dict((x,1) for x in range(10000))
52-
distribution_b = dict((x,1) for x in range(20))
53-
self.assertMatchesSciPy(distribution_a, distribution_b)
54-
55-
def assertMatchesSciPy(self, distribution_a, distribution_b):
56-
our_u, our_p = mann_whitney(distribution_a, distribution_b)
57-
correct_u, correct_p = scipy_mann_whitney(
58-
self.frequencies_to_list(distribution_a),
59-
self.frequencies_to_list(distribution_b))
60-
self.assertEqual(our_u, correct_u, "U score incorrect")
61-
self.assertAlmostEqual(our_p, correct_p, msg="p value incorrect")
62-
63-
64-
class CounterTestCase(TestCase):
65-
def setUp(self):
66-
counters.reset(TEST_KEY)
67-
self.assertEqual(counters.get(TEST_KEY), 0)
68-
69-
def tearDown(self):
70-
counters.reset(TEST_KEY)
71-
72-
def test_add_item(self):
73-
counters.increment(TEST_KEY, 'fred')
74-
self.assertEqual(counters.get(TEST_KEY), 1)
75-
76-
def test_add_multiple_items(self):
77-
counters.increment(TEST_KEY, 'fred')
78-
counters.increment(TEST_KEY, 'barney')
79-
counters.increment(TEST_KEY, 'george')
80-
counters.increment(TEST_KEY, 'george')
81-
self.assertEqual(counters.get(TEST_KEY), 3)
82-
83-
def test_add_duplicate_item(self):
84-
counters.increment(TEST_KEY, 'fred')
85-
counters.increment(TEST_KEY, 'fred')
86-
counters.increment(TEST_KEY, 'fred')
87-
self.assertEqual(counters.get(TEST_KEY), 1)
88-
89-
def test_get_frequencies(self):
90-
counters.increment(TEST_KEY, 'fred')
91-
counters.increment(TEST_KEY, 'barney')
92-
counters.increment(TEST_KEY, 'george')
93-
counters.increment(TEST_KEY, 'roger')
94-
counters.increment(TEST_KEY, 'roger')
95-
counters.increment(TEST_KEY, 'roger')
96-
counters.increment(TEST_KEY, 'roger')
97-
self.assertEqual(counters.get_frequencies(TEST_KEY), {1: 3, 4: 1})
98-
99-
100-
def test_delete_key(self):
101-
counters.increment(TEST_KEY, 'fred')
102-
counters.reset(TEST_KEY)
103-
self.assertEqual(counters.get(TEST_KEY), 0)
104-
105-
106-
10715
class WebUserTests:
10816
def setUp(self):
10917
self.experiment = Experiment(name='backgroundcolor', state=ENABLED_STATE)
@@ -182,10 +90,6 @@ def setUp(self):
18290
self.request.user = User(username='brian')
18391
self.request.user.save()
18492

185-
def tearDown(self):
186-
self.request.user.delete()
187-
super(WebUserAuthenticatedTestCase, self).tearDown()
188-
18993

19094
class BotTestCase(TestCase):
19195
def setUp(self):
@@ -201,7 +105,7 @@ def test_user_does_not_enroll(self):
201105
def test_bot_in_control_group(self):
202106
experiment_user = create_user(self.request)
203107
experiment_user.set_enrollment(self.experiment, TEST_ALTERNATIVE)
204-
self.assertEqual(experiment_user.get_enrollment(self.experiment), CONTROL_GROUP, "Bot alternative is not control")
108+
self.assertEqual(experiment_user.get_enrollment(self.experiment), None, "Bot enrolled in a group")
205109
self.assertEqual(experiment_user.is_enrolled(self.experiment.name, TEST_ALTERNATIVE, self.request), False, "Bot in test alternative")
206110
self.assertEqual(experiment_user.is_enrolled(self.experiment.name, CONTROL_GROUP, self.request), True, "Bot not in control group")
207111

0 commit comments

Comments
 (0)