Skip to content

Commit 3cd878c

Browse files
authored
Add is_feature_enabled API method (#58)
1 parent ef0ccdf commit 3cd878c

File tree

6 files changed

+460
-8
lines changed

6 files changed

+460
-8
lines changed

optimizely/bucketer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def _generate_bucket_value(self, bucketing_id):
6666
ratio = float(self._generate_unsigned_hash_code_32_bit(bucketing_id)) / MAX_HASH_VALUE
6767
return math.floor(ratio * MAX_TRAFFIC_VALUE)
6868

69-
def _find_bucket(self, user_id, parent_id, traffic_allocations):
69+
def find_bucket(self, user_id, parent_id, traffic_allocations):
7070
""" Determine entity based on bucket value and traffic allocations.
7171
7272
Args:
@@ -110,7 +110,7 @@ def bucket(self, experiment, user_id):
110110
if not group:
111111
return None
112112

113-
user_experiment_id = self._find_bucket(user_id, experiment.groupId, group.trafficAllocation)
113+
user_experiment_id = self.find_bucket(user_id, experiment.groupId, group.trafficAllocation)
114114
if not user_experiment_id:
115115
self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in no experiment.' % user_id)
116116
return None
@@ -124,7 +124,7 @@ def bucket(self, experiment, user_id):
124124
(user_id, experiment.key, experiment.groupId))
125125

126126
# Bucket user if not in white-list and in group (if any)
127-
variation_id = self._find_bucket(user_id, experiment.id, experiment.trafficAllocation)
127+
variation_id = self.find_bucket(user_id, experiment.id, experiment.trafficAllocation)
128128
if variation_id:
129129
variation = self.config.get_variation_from_id(experiment.key, variation_id)
130130
self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in variation "%s" of experiment %s.' %

optimizely/decision_service.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def get_stored_variation(self, experiment, user_profile):
7676

7777
return None
7878

79-
def get_variation(self, experiment, user_id, attributes):
79+
def get_variation(self, experiment, user_id, attributes, ignore_user_profile=False):
8080
""" Top-level function to help determine variation user should be put in.
8181
8282
First, check if experiment is running.
@@ -89,6 +89,7 @@ def get_variation(self, experiment, user_id, attributes):
8989
experiment_key: Experiment for which user variation needs to be determined.
9090
user_id: ID for user.
9191
attributes: Dict representing user attributes.
92+
ignore_user_profile: True to ignore the user profile lookup. Defaults to False.
9293
9394
Returns:
9495
Variation user should see. None if user is not in experiment or experiment is not running.
@@ -106,7 +107,7 @@ def get_variation(self, experiment, user_id, attributes):
106107

107108
# Check to see if user has a decision available for the given experiment
108109
user_profile = UserProfile(user_id)
109-
if self.user_profile_service:
110+
if not ignore_user_profile and self.user_profile_service:
110111
try:
111112
retrieved_profile = self.user_profile_service.lookup(user_id)
112113
except:
@@ -137,7 +138,7 @@ def get_variation(self, experiment, user_id, attributes):
137138

138139
if variation:
139140
# Store this new decision and return the variation for the user
140-
if self.user_profile_service:
141+
if not ignore_user_profile and self.user_profile_service:
141142
try:
142143
user_profile.save_variation_for_experiment(experiment.id, variation.id)
143144
self.user_profile_service.save(user_profile.__dict__)
@@ -148,3 +149,98 @@ def get_variation(self, experiment, user_id, attributes):
148149
return variation
149150

150151
return None
152+
153+
def get_variation_for_layer(self, layer, user_id, attributes=None, ignore_user_profile=False):
154+
""" Determine which variation the user is in for a given layer. Returns the variation of the first experiment the user qualifies for.
155+
156+
Args:
157+
layer: Layer for which we are getting the variation.
158+
user_id: ID for user.
159+
attributes: Dict representing user attributes.
160+
ignore_user_profile: True to ignore the user profile lookup. Defaults to False.
161+
162+
163+
Returns:
164+
Variation the user should see. None if the user is not in any of the layer's experiments.
165+
"""
166+
# Go through each experiment in order and try to get the variation for the user
167+
if layer:
168+
for experiment_dict in layer.experiments:
169+
experiment = self.config.get_experiment_from_key(experiment_dict['key'])
170+
variation = self.get_variation(experiment, user_id, attributes, ignore_user_profile)
171+
if variation:
172+
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is in variation %s of experiment %s.' % (user_id, variation.key, experiment.key))
173+
# Return as soon as we get a variation
174+
return variation
175+
176+
return None
177+
178+
def get_variation_for_feature(self, feature, user_id, attributes=None):
179+
""" Returns the variation the user is bucketed in for the given feature.
180+
181+
Args:
182+
feature: Feature for which we are determining if it is enabled or not for the given user.
183+
user_id: ID for user.
184+
attributes: Dict representing user attributes.
185+
186+
Returns:
187+
Variation that the user is bucketed in. None if the user is not in any variation.
188+
"""
189+
variation = None
190+
191+
# First check if the feature is in a mutex group
192+
if feature.groupId:
193+
group = self.config.get_group(feature.groupId)
194+
if group:
195+
experiment = self.get_experiment_in_group(group, user_id)
196+
if experiment and experiment.id in feature.experimentIds:
197+
variation = self.get_variation(experiment, user_id, attributes)
198+
199+
if variation:
200+
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is in variation %s of experiment %s.' % (user_id, variation.key, experiment.key))
201+
else:
202+
self.logger.log(enums.LogLevels.ERROR, enums.Errors.INVALID_GROUP_ID_ERROR.format('_get_variation_for_feature'))
203+
204+
# Next check if the feature is being experimented on
205+
elif feature.experimentIds:
206+
# If an experiment is not in a group, then the feature can only be associated with one experiment
207+
experiment = self.config.get_experiment_from_id(feature.experimentIds[0])
208+
if experiment:
209+
variation = self.get_variation(experiment, user_id, attributes)
210+
211+
if variation:
212+
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is in variation %s of experiment %s.' % (user_id, variation.key, experiment.key))
213+
214+
# Next check if user is part of a rollout
215+
if not variation and feature.layerId:
216+
layer = self.config.get_layer_from_id(feature.layerId)
217+
variation = self.get_variation_for_layer(layer, user_id, attributes, ignore_user_profile=True)
218+
219+
return variation
220+
221+
def get_experiment_in_group(self, group, user_id):
222+
""" Determine which experiment in the group the user is bucketed into.
223+
224+
Args:
225+
group: The group to bucket the user into.
226+
user_id: ID of the user.
227+
228+
Returns:
229+
Experiment if the user is bucketed into an experiment in the specified group. None otherwise.
230+
"""
231+
232+
experiment_id = self.bucketer.find_bucket(user_id, group.id, group.trafficAllocation)
233+
if experiment_id:
234+
experiment = self.config.get_experiment_from_id(experiment_id)
235+
if experiment:
236+
self.logger.log(enums.LogLevels.INFO,
237+
'User "%s" is in experiment %s of group %s.' %
238+
(user_id, experiment.key, group.id))
239+
return experiment
240+
241+
self.logger.log(enums.LogLevels.INFO,
242+
'User "%s" is not in any experiments of group %s.' %
243+
(user_id, group.id))
244+
245+
return None
246+

optimizely/optimizely.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,52 @@ def get_variation(self, experiment_key, user_id, attributes=None):
275275
return variation.key
276276

277277
return None
278+
279+
def is_feature_enabled(self, feature_key, user_id, attributes=None):
280+
""" Returns true if the feature is enabled for the given user.
281+
282+
Args:
283+
feature_key: The key of the feature for which we are determining if it is enabled or not for the given user.
284+
user_id: ID for user.
285+
attributes: Dict representing user attributes.
286+
287+
Returns:
288+
True if the feature is enabled for the user. False otherwise.
289+
"""
290+
if not self.is_valid:
291+
self.logger.log(enums.LogLevels.ERROR, enums.Errors.INVALID_DATAFILE.format('is_feature_enabled'))
292+
return False
293+
294+
feature = self.config.get_feature_from_key(feature_key)
295+
if not feature:
296+
return False
297+
298+
variation = self.decision_service.get_variation_for_feature(feature, user_id, attributes)
299+
if variation:
300+
self.logger.log(enums.LogLevels.INFO, 'Feature "%s" is enabled for user "%s".' % (feature_key, user_id))
301+
return True
302+
303+
self.logger.log(enums.LogLevels.INFO, 'Feature "%s" is not enabled for user "%s".' % (feature_key, user_id))
304+
return False
305+
306+
def get_enabled_features(self, user_id, attributes=None):
307+
""" Returns the list of features that are enabled for the user.
308+
309+
Args:
310+
user_id: ID for user.
311+
attributes: Dict representing user attributes.
312+
313+
Returns:
314+
A list of the keys of the features that are enabled for the user.
315+
316+
"""
317+
if not self.is_valid:
318+
self.logger.log(enums.LogLevels.ERROR, enums.Errors.INVALID_DATAFILE.format('get_enabled_features'))
319+
return False
320+
321+
enabled_features = []
322+
for feature in self.config.feature_key_map.values():
323+
if self.is_feature_enabled(feature.key, user_id, attributes):
324+
enabled_features.append(feature.key)
325+
326+
return enabled_features

tests/base.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,66 @@ def setUp(self):
172172
'id': '111129'
173173
}]
174174
}],
175-
'groups': [],
175+
'groups': [{
176+
'id': '19228',
177+
'policy': 'random',
178+
'experiments': [{
179+
'id': '32222',
180+
'key': 'group_exp_1',
181+
'status': 'Running',
182+
'audienceIds': [],
183+
'layerId': '111183',
184+
'variations': [{
185+
'key': 'group_exp_1_control',
186+
'id': '28901'
187+
}, {
188+
'key': 'group_exp_1_variation',
189+
'id': '28902'
190+
}],
191+
'forcedVariations': {
192+
'user_1': 'group_exp_1_control',
193+
'user_2': 'group_exp_1_control'
194+
},
195+
'trafficAllocation': [{
196+
'entityId': '28901',
197+
'endOfRange': 3000
198+
}, {
199+
'entityId': '28902',
200+
'endOfRange': 9000
201+
}]
202+
}, {
203+
'id': '32223',
204+
'key': 'group_exp_2',
205+
'status': 'Running',
206+
'audienceIds': [],
207+
'layerId': '111184',
208+
'variations': [{
209+
'key': 'group_exp_2_control',
210+
'id': '28905'
211+
}, {
212+
'key': 'group_exp_2_variation',
213+
'id': '28906'
214+
}],
215+
'forcedVariations': {
216+
'user_1': 'group_exp_2_control',
217+
'user_2': 'group_exp_2_control'
218+
},
219+
'trafficAllocation': [{
220+
'entityId': '28905',
221+
'endOfRange': 8000
222+
}, {
223+
'entityId': '28906',
224+
'endOfRange': 10000
225+
}]
226+
}],
227+
'trafficAllocation': [{
228+
'entityId': '32222',
229+
"endOfRange": 3000
230+
}, {
231+
'entityId': '32223',
232+
'endOfRange': 7500
233+
}]
234+
}],
176235
'attributes': [{
177236
'key': 'test_attribute',
178237
'id': '111094'
@@ -231,6 +290,18 @@ def setUp(self):
231290
'experimentIds': [],
232291
'layerId': '211111',
233292
'variables': [],
293+
}, {
294+
'id': '91113',
295+
'key': 'test_feature_in_group',
296+
'experimentIds': ['32222'],
297+
'layerId': '',
298+
'variables': [],
299+
}, {
300+
'id': '91114',
301+
'key': 'test_feature_in_experiment_and_rollout',
302+
'experimentIds': ['111127'],
303+
'layerId': '211111',
304+
'variables': [],
234305
}]
235306
}
236307

0 commit comments

Comments
 (0)