Skip to content

Commit 58977d2

Browse files
committed
feat: add remaining implementation
1 parent cee1fb8 commit 58977d2

File tree

5 files changed

+544
-401
lines changed

5 files changed

+544
-401
lines changed

optimizely/decision_service.py

Lines changed: 150 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# limitations under the License.
1313

1414
from collections import namedtuple
15+
1516
from six import string_types
1617

1718
from . import bucketer
@@ -21,7 +22,6 @@
2122
from .helpers import validator
2223
from .user_profile import UserProfile
2324

24-
2525
Decision = namedtuple('Decision', 'experiment variation source')
2626

2727

@@ -211,7 +211,7 @@ def get_stored_variation(self, project_config, experiment, user_profile):
211211
if variation_id:
212212
variation = project_config.get_variation_from_id(experiment.key, variation_id)
213213
if variation:
214-
message = 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".'\
214+
message = 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' \
215215
% (user_id, variation.key, experiment.key)
216216
self.logger.info(
217217
message
@@ -221,7 +221,7 @@ def get_stored_variation(self, project_config, experiment, user_profile):
221221
return None
222222

223223
def get_variation(
224-
self, project_config, experiment, user_id, attributes, ignore_user_profile=False
224+
self, project_config, experiment, user_context, ignore_user_profile=False
225225
):
226226
""" Top-level function to help determine variation user should be put in.
227227
@@ -234,14 +234,17 @@ def get_variation(
234234
Args:
235235
project_config: Instance of ProjectConfig.
236236
experiment: Experiment for which user variation needs to be determined.
237-
user_id: ID for user.
238-
attributes: Dict representing user attributes.
237+
user_context: contains user id and attributes
239238
ignore_user_profile: True to ignore the user profile lookup. Defaults to False.
240239
241240
Returns:
242241
Variation user should see. None if user is not in experiment or experiment is not running
243242
And an array of log messages representing decision making.
244243
"""
244+
245+
user_id = user_context.user_id
246+
attributes = user_context.get_user_attributes()
247+
245248
decide_reasons = []
246249
# Check if experiment is running
247250
if not experiment_helper.is_experiment_running(experiment):
@@ -323,110 +326,174 @@ def get_variation(
323326
decide_reasons.append(message)
324327
return None, decide_reasons
325328

326-
def get_variation_for_rollout(self, project_config, rollout, user_id, attributes=None):
329+
def get_variation_for_rollout(self, project_config, rollout, user, options):
327330
""" Determine which experiment/variation the user is in for a given rollout.
328331
Returns the variation of the first experiment the user qualifies for.
329332
330333
Args:
331334
project_config: Instance of ProjectConfig.
332335
rollout: Rollout for which we are getting the variation.
333-
user_id: ID for user.
334-
attributes: Dict representing user attributes.
336+
user: ID and attributes for user.
337+
options: Decide options.
335338
336339
Returns:
337340
Decision namedtuple consisting of experiment and variation for the user and
338341
array of log messages representing decision making.
339342
"""
343+
user_id = user.user_id
344+
attributes = user.get_user_attributes()
340345
decide_reasons = []
341-
# Go through each experiment in order and try to get the variation for the user
342-
if rollout and len(rollout.experiments) > 0:
343-
for idx in range(len(rollout.experiments) - 1):
344-
logging_key = str(idx + 1)
345-
rollout_rule = project_config.get_experiment_from_id(rollout.experiments[idx].get('id'))
346-
347-
# Check if user meets audience conditions for targeting rule
348-
audience_conditions = rollout_rule.get_audience_conditions_or_ids()
349-
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
350-
project_config,
351-
audience_conditions,
352-
enums.RolloutRuleAudienceEvaluationLogs,
353-
logging_key,
354-
attributes,
355-
self.logger)
346+
rollout_rules = project_config.get_rollout_experiments_map(rollout)
347+
348+
if rollout and len(rollout_rules) > 0:
349+
index = 0
350+
while index < len(rollout_rules):
351+
decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config,
352+
rollout_rules[index].key,
353+
rollout_rules, index, user,
354+
options)
356355
decide_reasons += reasons_received
357-
if not user_meets_audience_conditions:
358-
message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key)
359-
self.logger.debug(
360-
message
361-
)
362-
decide_reasons.append(message)
363-
continue
364-
message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1)
356+
357+
if decision_response:
358+
variation, skip_to_everyone_else = decision_response
359+
360+
if variation:
361+
rule = rollout_rules[index]
362+
feature_decision = Decision(experiment=rule, variation=variation,
363+
source=enums.DecisionSources.ROLLOUT)
364+
365+
return feature_decision, decide_reasons
366+
367+
# the last rule is special for "Everyone Else"
368+
index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1
369+
370+
return None, decide_reasons
371+
372+
def get_variation_from_experiment_rule(self, config, flag_key, rule, user, options):
373+
""" Checks for experiment rule if decision is forced and returns it.
374+
Otherwise returns a regular decision.
375+
376+
Args:
377+
config: Instance of ProjectConfig.
378+
flag_key: Key of the flag.
379+
rule: Experiment rule.
380+
user: ID and attributes for user.
381+
options: Decide options.
382+
383+
Returns:
384+
Decision namedtuple consisting of experiment and variation for the user and
385+
array of log messages representing decision making.
386+
"""
387+
decide_reasons = []
388+
389+
# check forced decision first
390+
forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options)
391+
decide_reasons += reasons_received
392+
393+
if forced_decision_variation:
394+
return forced_decision_variation, decide_reasons
395+
396+
# regular decision
397+
decision_variation, variation_reasons = self.get_variation(config, rule, user, options)
398+
decide_reasons += variation_reasons
399+
return decision_variation, decide_reasons
400+
401+
def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, user, options):
402+
""" Checks for delivery rule if decision is forced and returns it.
403+
Otherwise returns a regular decision.
404+
405+
Args:
406+
config: Instance of ProjectConfig.
407+
flag_key: Key of the flag.
408+
rules: Experiment rule.
409+
user: ID and attributes for user.
410+
options: Decide options.
411+
412+
Returns:
413+
If forced decision, it returns namedtuple consisting of forced_decision_variation and skip_to_everyone_else
414+
and decision reason log messages.
415+
416+
If regular decision it returns a tuple of bucketed_variation and skip_to_everyone_else
417+
and decision reason log messages
418+
"""
419+
decide_reasons = []
420+
skip_to_everyone_else = False
421+
bucketed_variation = None
422+
423+
# check forced decision first
424+
rule = rules[rule_index]
425+
forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options)
426+
decide_reasons += reasons_received
427+
428+
if forced_decision_variation:
429+
return (forced_decision_variation, skip_to_everyone_else), decide_reasons
430+
431+
# regular decision
432+
user_id = user.user_id
433+
attributes = user.get_user_attributes()
434+
bucketing_id = self._get_bucketing_id(user_id, attributes)
435+
436+
everyone_else = (rule_index == len(rules) - 1)
437+
logging_key = "Everyone Else" if everyone_else else str(rule_index + 1)
438+
439+
rollout_rule = config.get_experiment_from_id(rule.id)
440+
audience_conditions = rollout_rule.get_audience_conditions_or_ids()
441+
442+
audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions(
443+
config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, logging_key, attributes, self.logger)
444+
# TODO - add regular logger here, and add log to reasons
445+
decide_reasons += reasons_received_audience
446+
447+
if audience_decision_response:
448+
449+
message = 'User "{}" meets conditions for targeting rule {}.'.format(user_id, logging_key)
450+
self.logger.debug(message)
451+
decide_reasons.append(message)
452+
453+
bucketed_variation, bucket_reasons = self.bucketer.bucket(config, rollout_rule, user_id,
454+
bucketing_id) # used this from existing, now old code
455+
decide_reasons.append(bucket_reasons)
456+
457+
if bucketed_variation:
458+
message = 'User "{}" bucketed into a targeting rule {}.'.format(user_id, logging_key)
459+
self.logger.debug(message)
460+
decide_reasons.append(message)
461+
462+
elif not everyone_else:
463+
# skip this logging for EveryoneElse since this has a message not for everyone_else
464+
message = 'User "{}" not bucketed into a targeting rule {}.'.format(user_id,
465+
logging_key)
365466
self.logger.debug(message)
366467
decide_reasons.append(message)
367-
# Determine bucketing ID to be used
368-
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
369-
decide_reasons += bucket_reasons
370-
variation, reasons = self.bucketer.bucket(project_config, rollout_rule, user_id, bucketing_id)
371-
decide_reasons += reasons
372-
if variation:
373-
message = 'User "{}" is in the traffic group of targeting rule {}.'.format(user_id, logging_key)
374-
self.logger.debug(
375-
message
376-
)
377-
decide_reasons.append(message)
378-
return Decision(rollout_rule, variation, enums.DecisionSources.ROLLOUT), decide_reasons
379-
else:
380-
message = 'User "{}" is not in the traffic group for targeting rule {}. ' \
381-
'Checking "Everyone Else" rule now.'.format(user_id, logging_key)
382-
# Evaluate no further rules
383-
self.logger.debug(
384-
message
385-
)
386-
decide_reasons.append(message)
387-
break
388-
389-
# Evaluate last rule i.e. "Everyone Else" rule
390-
everyone_else_rule = project_config.get_experiment_from_id(rollout.experiments[-1].get('id'))
391-
audience_conditions = everyone_else_rule.get_audience_conditions_or_ids()
392-
audience_eval, audience_reasons = audience_helper.does_user_meet_audience_conditions(
393-
project_config,
394-
audience_conditions,
395-
enums.RolloutRuleAudienceEvaluationLogs,
396-
'Everyone Else',
397-
attributes,
398-
self.logger
399-
)
400-
decide_reasons += audience_reasons
401-
if audience_eval:
402-
# Determine bucketing ID to be used
403-
bucketing_id, bucket_id_reasons = self._get_bucketing_id(user_id, attributes)
404-
decide_reasons += bucket_id_reasons
405-
variation, bucket_reasons = self.bucketer.bucket(
406-
project_config, everyone_else_rule, user_id, bucketing_id)
407-
decide_reasons += bucket_reasons
408-
if variation:
409-
message = 'User "{}" meets conditions for targeting rule "Everyone Else".'.format(user_id)
410-
self.logger.debug(message)
411-
decide_reasons.append(message)
412-
return Decision(everyone_else_rule, variation, enums.DecisionSources.ROLLOUT,), decide_reasons
413468

414-
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
469+
# skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
470+
skip_to_everyone_else = True
415471

416-
def get_variation_for_feature(self, project_config, feature, user_id, attributes=None, ignore_user_profile=False):
472+
else:
473+
message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key)
474+
self.logger.debug(message)
475+
decide_reasons.append(message)
476+
477+
return (bucketed_variation, skip_to_everyone_else), decide_reasons
478+
479+
def get_variation_for_feature(self, project_config, feature, user_context, ignore_user_profile=False):
417480
""" Returns the experiment/variation the user is bucketed in for the given feature.
418481
419482
Args:
420483
project_config: Instance of ProjectConfig.
421484
feature: Feature for which we are determining if it is enabled or not for the given user.
422-
user_id: ID for user.
485+
user: user context for user.
423486
attributes: Dict representing user attributes.
424487
ignore_user_profile: True if we should bypass the user profile service
425488
426489
Returns:
427490
Decision namedtuple consisting of experiment and variation for the user.
428491
"""
492+
user_id = user_context.user_id
493+
attributes = user_context.get_user_attributes()
494+
429495
decide_reasons = []
496+
430497
bucketing_id, reasons = self._get_bucketing_id(user_id, attributes)
431498
decide_reasons += reasons
432499

@@ -436,15 +503,15 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes
436503
for experiment in feature.experimentIds:
437504
experiment = project_config.get_experiment_from_id(experiment)
438505
if experiment:
439-
variation, variation_reasons = self.get_variation(
440-
project_config, experiment, user_id, attributes, ignore_user_profile)
506+
variation, variation_reasons = self.get_variation_from_experiment_rule(
507+
project_config, feature.key, experiment, user_context, ignore_user_profile)
441508
decide_reasons += variation_reasons
442509
if variation:
443510
return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons
444511

445512
# Next check if user is part of a rollout
446513
if feature.rolloutId:
447514
rollout = project_config.get_rollout_from_id(feature.rolloutId)
448-
return self.get_variation_for_rollout(project_config, rollout, user_id, attributes)
515+
return self.get_variation_for_rollout(project_config, rollout, user_context, ignore_user_profile)
449516
else:
450517
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons

optimizely/helpers/enums.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ class Errors(object):
115115
UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".'
116116

117117

118+
class ForcedDecisionNotificationTypes(object):
119+
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.'
120+
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}" and user "{}" in the forced decision map.'
121+
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.'
122+
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}" and user "{}" in the forced decision map.'
123+
124+
118125
class HTTPHeaders(object):
119126
AUTHORIZATION = 'Authorization'
120127
IF_MODIFIED_SINCE = 'If-Modified-Since'

0 commit comments

Comments
 (0)