|
| 1 | +import typing |
| 2 | +from collections import defaultdict |
| 3 | + |
| 4 | +from flag_engine.context.types import ( |
| 5 | + EvaluationContext, |
| 6 | + SegmentContext, |
| 7 | + FeatureContext, |
| 8 | + SegmentRule, |
| 9 | +) |
| 10 | +from flag_engine.environments.models import EnvironmentModel |
| 11 | +from flag_engine.features.models import ( |
| 12 | + FeatureStateModel, |
| 13 | + MultivariateFeatureStateValueModel, |
| 14 | +) |
| 15 | +from flag_engine.identities.models import IdentityModel |
| 16 | +from flag_engine.identities.traits.models import TraitModel |
| 17 | +from flag_engine.segments.models import SegmentRuleModel |
| 18 | + |
| 19 | +OverrideKey = typing.Tuple[ |
| 20 | + str, |
| 21 | + str, |
| 22 | + bool, |
| 23 | + typing.Any, |
| 24 | +] |
| 25 | +OverridesKey = typing.Tuple[OverrideKey, ...] |
| 26 | + |
| 27 | + |
| 28 | +def map_environment_identity_to_context( |
| 29 | + environment: EnvironmentModel, |
| 30 | + identity: IdentityModel, |
| 31 | + override_traits: typing.Optional[typing.List[TraitModel]], |
| 32 | +) -> EvaluationContext: |
| 33 | + """ |
| 34 | + Map an EnvironmentModel and IdentityModel to an EvaluationContext. |
| 35 | +
|
| 36 | + :param environment: The environment model object. |
| 37 | + :param identity: The identity model object. |
| 38 | + :param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided. |
| 39 | + :return: An EvaluationContext containing the environment and identity. |
| 40 | + """ |
| 41 | + features = map_feature_states_to_feature_contexts(environment.feature_states) |
| 42 | + segments: typing.Dict[str, SegmentContext] = {} |
| 43 | + for segment in environment.project.segments: |
| 44 | + segment_ctx_data: SegmentContext = { |
| 45 | + "key": str(segment.id), |
| 46 | + "name": segment.name, |
| 47 | + "rules": map_segment_rules_to_segment_context_rules(segment.rules), |
| 48 | + } |
| 49 | + if segment_feature_states := segment.feature_states: |
| 50 | + segment_ctx_data["overrides"] = list( |
| 51 | + map_feature_states_to_feature_contexts(segment_feature_states).values() |
| 52 | + ) |
| 53 | + segments[segment.name] = segment_ctx_data |
| 54 | + # Concatenate feature states overriden for identities |
| 55 | + # to segment contexts |
| 56 | + features_to_identifiers: typing.Dict[ |
| 57 | + OverridesKey, |
| 58 | + typing.List[str], |
| 59 | + ] = defaultdict(list) |
| 60 | + for identity_override in (*environment.identity_overrides, identity): |
| 61 | + identity_features: typing.List[FeatureStateModel] = ( |
| 62 | + identity_override.identity_features |
| 63 | + ) |
| 64 | + if not identity_features: |
| 65 | + continue |
| 66 | + overrides_key = tuple( |
| 67 | + ( |
| 68 | + str(feature_state.feature.id), |
| 69 | + feature_state.feature.name, |
| 70 | + feature_state.enabled, |
| 71 | + feature_state.feature_state_value, |
| 72 | + ) |
| 73 | + for feature_state in sorted(identity_features, key=_get_name) |
| 74 | + ) |
| 75 | + features_to_identifiers[overrides_key].append(identity_override.identifier) |
| 76 | + for overrides_key, identifiers in features_to_identifiers.items(): |
| 77 | + segment_name = f"overrides_{abs(hash(overrides_key))}" |
| 78 | + segments[segment_name] = SegmentContext( |
| 79 | + key="", # Identity override segments never use % Split operator |
| 80 | + name=segment_name, |
| 81 | + rules=[ |
| 82 | + { |
| 83 | + "type": "ALL", |
| 84 | + "rules": [ |
| 85 | + { |
| 86 | + "type": "ALL", |
| 87 | + "conditions": [ |
| 88 | + { |
| 89 | + "property": "$.identity.identifier", |
| 90 | + "operator": "IN", |
| 91 | + "value": ",".join(identifiers), |
| 92 | + } |
| 93 | + ], |
| 94 | + } |
| 95 | + ], |
| 96 | + } |
| 97 | + ], |
| 98 | + overrides=[ |
| 99 | + { |
| 100 | + "key": "", # Identity overrides never carry multivariate options |
| 101 | + "feature_key": feature_key, |
| 102 | + "name": feature_name, |
| 103 | + "enabled": feature_enabled, |
| 104 | + "value": feature_value, |
| 105 | + "priority": float("-inf"), # Highest possible priority |
| 106 | + } |
| 107 | + for feature_key, feature_name, feature_enabled, feature_value in overrides_key |
| 108 | + ], |
| 109 | + ) |
| 110 | + return { |
| 111 | + "environment": { |
| 112 | + "key": environment.api_key, |
| 113 | + "name": environment.name or "", |
| 114 | + }, |
| 115 | + "identity": { |
| 116 | + "identifier": identity.identifier, |
| 117 | + "key": str(identity.django_id or identity.composite_key), |
| 118 | + "traits": { |
| 119 | + trait.trait_key: trait.trait_value |
| 120 | + for trait in ( |
| 121 | + override_traits |
| 122 | + if override_traits is not None |
| 123 | + else identity.identity_traits |
| 124 | + ) |
| 125 | + }, |
| 126 | + }, |
| 127 | + "features": features, |
| 128 | + "segments": segments, |
| 129 | + } |
| 130 | + |
| 131 | + |
| 132 | +def map_feature_states_to_feature_contexts( |
| 133 | + feature_states: typing.List[FeatureStateModel], |
| 134 | +) -> typing.Dict[str, FeatureContext]: |
| 135 | + """ |
| 136 | + Map feature states to feature contexts. |
| 137 | +
|
| 138 | + :param feature_states: A list of FeatureStateModel objects. |
| 139 | + :return: A dictionary mapping feature names to their contexts. |
| 140 | + """ |
| 141 | + features: typing.Dict[str, FeatureContext] = {} |
| 142 | + for feature_state in feature_states: |
| 143 | + feature_ctx_data: FeatureContext = { |
| 144 | + "key": str(feature_state.django_id or feature_state.featurestate_uuid), |
| 145 | + "feature_key": str(feature_state.feature.id), |
| 146 | + "name": feature_state.feature.name, |
| 147 | + "enabled": feature_state.enabled, |
| 148 | + "value": feature_state.feature_state_value, |
| 149 | + } |
| 150 | + multivariate_feature_state_values: typing.List[ |
| 151 | + MultivariateFeatureStateValueModel |
| 152 | + ] |
| 153 | + if ( |
| 154 | + multivariate_feature_state_values |
| 155 | + := feature_state.multivariate_feature_state_values |
| 156 | + ): |
| 157 | + feature_ctx_data["variants"] = [ |
| 158 | + { |
| 159 | + "value": multivariate_feature_state_value.multivariate_feature_option.value, |
| 160 | + "weight": multivariate_feature_state_value.percentage_allocation, |
| 161 | + } |
| 162 | + for multivariate_feature_state_value in sorted( |
| 163 | + multivariate_feature_state_values, |
| 164 | + key=_get_multivariate_feature_state_value_id, |
| 165 | + ) |
| 166 | + ] |
| 167 | + if feature_segment := feature_state.feature_segment: |
| 168 | + if (priority := feature_segment.priority) is not None: |
| 169 | + feature_ctx_data["priority"] = priority |
| 170 | + features[feature_state.feature.name] = feature_ctx_data |
| 171 | + return features |
| 172 | + |
| 173 | + |
| 174 | +def _get_multivariate_feature_state_value_id( |
| 175 | + multivariate_feature_state_value: MultivariateFeatureStateValueModel, |
| 176 | +) -> int: |
| 177 | + return ( |
| 178 | + multivariate_feature_state_value.id |
| 179 | + or multivariate_feature_state_value.mv_fs_value_uuid.int |
| 180 | + ) |
| 181 | + |
| 182 | + |
| 183 | +def map_segment_rules_to_segment_context_rules( |
| 184 | + rules: typing.List[SegmentRuleModel], |
| 185 | +) -> typing.List[SegmentRule]: |
| 186 | + """ |
| 187 | + Map segment rules to segment rules for the evaluation context. |
| 188 | +
|
| 189 | + :param rules: A list of SegmentRuleModel objects. |
| 190 | + :return: A list of SegmentRule objects. |
| 191 | + """ |
| 192 | + return [ |
| 193 | + { |
| 194 | + "type": rule.type, |
| 195 | + "conditions": [ |
| 196 | + { |
| 197 | + "property": condition.property_ or "", |
| 198 | + "operator": condition.operator, |
| 199 | + "value": condition.value or "", |
| 200 | + } |
| 201 | + for condition in rule.conditions |
| 202 | + ], |
| 203 | + "rules": map_segment_rules_to_segment_context_rules(rule.rules), |
| 204 | + } |
| 205 | + for rule in rules |
| 206 | + ] |
| 207 | + |
| 208 | + |
| 209 | +def _get_name(feature_state: FeatureStateModel) -> str: |
| 210 | + return feature_state.feature.name |
0 commit comments