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