From 42634ece4724b869f21e13fc4818e93406c5e0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fa=C3=A7=20Eldenk?= Date: Fri, 9 Feb 2024 09:33:30 -0600 Subject: [PATCH] feat: try importing rules from LD flags (#3233) --- api/integrations/launch_darkly/client.py | 65 +- api/integrations/launch_darkly/models.py | 9 +- api/integrations/launch_darkly/serializers.py | 4 +- api/integrations/launch_darkly/services.py | 761 +++++++++++- api/integrations/launch_darkly/types.py | 105 +- api/ld-openapi-filter.yaml | 2 + .../client_responses/get_environments.json | 52 +- .../client_responses/get_flags.json | 1085 ++++++++++++++++- .../client_responses/get_segments.json | 159 +++ .../integrations/launch_darkly/conftest.py | 3 +- .../launch_darkly/test_services.py | 325 ++++- .../integrations/launch_darkly/test_views.py | 4 +- 12 files changed, 2483 insertions(+), 91 deletions(-) create mode 100644 api/tests/unit/integrations/launch_darkly/client_responses/get_segments.json diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 4164efc21f43..aaaee6a4ea80 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -1,4 +1,4 @@ -from typing import Any, Iterator, Optional +from typing import Any, Iterator, Optional, TypeVar from requests import Session @@ -9,6 +9,8 @@ LAUNCH_DARKLY_API_VERSION, ) +T = TypeVar("T") + class LaunchDarklyClient: def __init__(self, token: str) -> None: @@ -25,7 +27,7 @@ def _get_json_response( self, endpoint: str, params: Optional[dict[str, Any]] = None, - ) -> dict[str, Any]: + ) -> T: full_url = f"{LAUNCH_DARKLY_API_BASE_URL}{endpoint}" response = self.client_session.get(full_url, params=params) response.raise_for_status() @@ -34,9 +36,20 @@ def _get_json_response( def _iter_paginated_items( self, collection_endpoint: str, - additional_params: Optional[dict[str, str]] = None, - ) -> Iterator[dict[str, Any]]: + additional_params: Optional[dict[str, Any]] = None, + use_legacy_offset_pagination: bool = False, + ) -> Iterator[T]: + """ + Iterator over paginated items in the given collection endpoint. + + :param collection_endpoint: endpoint to get the collection of items + :param additional_params: Additional parameters to include in the request + :param use_legacy_offset_pagination: Whether to use offset based pagination if `next` links do not + exist in the response. Some endpoints do not have `next` links and require offset based pagination. + :return: Iterator over the items in the collection + """ params = {"limit": LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE} + offset = 0 if additional_params: params.update(additional_params) @@ -45,7 +58,8 @@ def _iter_paginated_items( params=params, ) while True: - yield from response_json.get("items") or [] + items = response_json.get("items") or [] + yield from items links: Optional[dict[str, ld_types.Link]] = response_json.get("_links") if ( links @@ -57,6 +71,14 @@ def _iter_paginated_items( response_json = self._get_json_response( endpoint=next_endpoint, ) + elif use_legacy_offset_pagination and len(items) == params["limit"]: + # Offset based pagination + offset += params["limit"] + params["offset"] = offset + response_json = self._get_json_response( + endpoint=collection_endpoint, + params=params, + ) else: return @@ -82,6 +104,9 @@ def get_flags(self, project_key: str) -> list[ld_types.FeatureFlag]: return list( self._iter_paginated_items( collection_endpoint=endpoint, + # Summary should be set to 0 in order to get the full flag data including rules. + # https://apidocs.launchdarkly.com/tag/Feature-flags#operation/getFeatureFlags!in=query&path=summary&t=request + additional_params={"summary": "0"}, ) ) @@ -106,3 +131,33 @@ def get_flag_tags(self) -> list[str]: additional_params={"kind": "flag"}, ) ) + + def get_segment_tags(self) -> list[str]: + """operationId: getTags""" + endpoint = "/api/v2/tags" + return list( + self._iter_paginated_items( + collection_endpoint=endpoint, + additional_params={"kind": "segment"}, + ) + ) + + def get_segments( + self, project_key: str, environment_key: str + ) -> list[ld_types.UserSegment]: + """operationId: getSegments""" + endpoint = f"/api/v2/segments/{project_key}/{environment_key}" + return list( + self._iter_paginated_items( + collection_endpoint=endpoint, + additional_params={"limit": 50}, + use_legacy_offset_pagination=True, + ) + ) + + def get_segment( + self, project_key: str, environment_key: str, segment_key: str + ) -> ld_types.UserSegment: + """operationId: getSegment""" + endpoint = f"/api/v2/segments/{project_key}/{environment_key}/{segment_key}" + return self._get_json_response(endpoint=endpoint) diff --git a/api/integrations/launch_darkly/models.py b/api/integrations/launch_darkly/models.py index 2431c702a229..726691b4a1c6 100644 --- a/api/integrations/launch_darkly/models.py +++ b/api/integrations/launch_darkly/models.py @@ -15,7 +15,7 @@ class LaunchDarklyImportStatus(TypedDict): requested_environment_count: int requested_flag_count: int result: NotRequired[Literal["success", "failure"]] - error_message: NotRequired[str] + error_messages: list[str] class LaunchDarklyImportRequest( @@ -44,8 +44,11 @@ def get_update_log_message(self, _) -> Optional[str]: return None if self.status.get("result") == "success": return "LaunchDarkly import completed successfully" - if error_message := self.status.get("error_message"): - return f"LaunchDarkly import failed with error: {error_message}" + if error_messages := self.status.get("error_messages"): + if len(error_messages) > 0: + return "LaunchDarkly import failed with errors:\n" + "\n".join( + "- " + error_message for error_message in error_messages + ) return "LaunchDarkly import failed" def get_audit_log_author(self) -> "FFAdminUser": diff --git a/api/integrations/launch_darkly/serializers.py b/api/integrations/launch_darkly/serializers.py index 4dc4c7af0222..f5d6f0709a8f 100644 --- a/api/integrations/launch_darkly/serializers.py +++ b/api/integrations/launch_darkly/serializers.py @@ -11,7 +11,9 @@ class LaunchDarklyImportRequestStatusSerializer(serializers.Serializer): read_only=True, allow_null=True, ) - error_message = serializers.CharField(read_only=True, allow_null=True) + error_messages = serializers.ListSerializer( + child=serializers.CharField(read_only=True) + ) class CreateLaunchDarklyImportRequestSerializer(serializers.Serializer): diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 8f74e20d51de..78d26eadef3b 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -1,13 +1,22 @@ +import logging +import re from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable, Tuple +from typing import Callable, Optional, Tuple from django.core import signing from django.utils import timezone +from flag_engine.segments import constants from requests.exceptions import RequestException +from environments.identities.models import Identity from environments.models import Environment from features.feature_types import MULTIVARIATE, STANDARD, FeatureType -from features.models import Feature, FeatureState, FeatureStateValue +from features.models import ( + Feature, + FeatureSegment, + FeatureState, + FeatureStateValue, +) from features.multivariate.models import ( MultivariateFeatureOption, MultivariateFeatureStateValue, @@ -23,11 +32,13 @@ LaunchDarklyImportRequest, LaunchDarklyImportStatus, ) +from integrations.launch_darkly.types import Clause +from projects.models import Project from projects.tags.models import Tag +from segments.models import Condition, Segment, SegmentRule +from users.models import FFAdminUser -if TYPE_CHECKING: # pragma: no cover - from projects.models import Project - from users.models import FFAdminUser +logger = logging.getLogger(__name__) def _sign_ld_value(value: str, user_id: int) -> str: @@ -45,7 +56,7 @@ def _log_error( import_request: LaunchDarklyImportRequest, error_message: str, ) -> None: - import_request.status["error_message"] = error_message + import_request.status["error_messages"] += [error_message] @contextmanager @@ -58,7 +69,7 @@ def _complete_import_request( If no exception raised, assume successful import. In case wrapped code needs to expose an error to the user, it should populate - `import_request.status["error_message"]` before raising an exception. + `import_request.status["error_messages"]` before raising an exception. """ try: yield @@ -109,15 +120,517 @@ def _create_tags_from_ld( return tags_by_ld_tag -def _create_boolean_feature_states( +def _ld_operator_to_flagsmith_operator(ld_operator: str) -> Optional[str]: + """ + Convert a Launch Darkly operator to its closest Flagsmith equivalent. If not convertible, return None. + + Based on: https://docs.launchdarkly.com/sdk/concepts/flag-evaluation-rules#operators + + :param ld_operator: the operator of the targeting rule. + :return: the closest Flagsmith equivalent of the given Launch Darkly operator. + """ + return { + "in": constants.IN, + "endsWith": constants.REGEX, + "startsWith": constants.REGEX, + "matches": constants.REGEX, + "contains": constants.CONTAINS, + "lessThan": constants.LESS_THAN, + "lessThanOrEqual": constants.LESS_THAN_INCLUSIVE, + "greaterThan": constants.GREATER_THAN, + "greaterThanOrEqual": constants.GREATER_THAN_INCLUSIVE, + "before": constants.LESS_THAN, + "after": constants.GREATER_THAN, + "semVerEqual": constants.EQUAL, + "semVerLessThan": constants.LESS_THAN, + "semVerGreaterThan": constants.GREATER_THAN, + }.get(ld_operator, None) + + +def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]: + """ + Convert "values" of a Launch Darkly clause to Flagsmith compatible values. Some matching is converted to + regex and some matching is consolidated into a single value. For example, if "in" operator is used, we join + the values using the comma separator to make it Flagsmith-compliant. + + Note that a separate Clause should be created for each value in the lis and those clauses should be "OR"ed. + This is how Launch Darkly handles multiple values for a single operator such as less than. + + :param values: the list of values from Launch Darkly's targeting rule. + :param ld_operator: the operator of the targeting rule. + :return: a list of values that is Flagsmith-compliant. + """ + match ld_operator: + case "in": + # TODO: How to escape the comma itself? + return list([",".join(values)]) + case "endsWith": + return [".*" + re.escape(value) for value in values] + case "startsWith": + return [re.escape(value) + ".*" for value in values] + case "semVerEqual" | "semVerLessThan" | "semVerGreaterThan": + return [value + ":semver" for value in values] + case _: + return [value for value in values] + + +def _get_segment_name(name: str, env: str) -> str: + """ + Generate a unique and descriptive name for the segment. This name is re-used on consecutive imports to + prevent duplicate segments. + + :param name: Name of the Launch Darkly segment. + :param env: Environment name of the Launch Darkly segment. + :return: A unique and descriptive name for the segment targeting a specific environment. + """ + return f"{name} (Override for {env})" + + +def _create_feature_segments_for_segment_match_clauses( + import_request: LaunchDarklyImportRequest, + clauses: list[Clause], + project: Project, + feature: Feature, + environment: Environment, + segments_by_ld_key: dict[str, Segment], +) -> list[FeatureSegment]: + """ + Creates a feature segment if a rule contains "segmentMatch" operator. This shouldn't be used if clauses + doesn't contain "segmentMatch" operator. Instead use "_create_feature_segment_from_clauses". + + This method can only accept clauses that contains "segmentMatch" operator. If there are other operators, + we can't create corresponding Feature Segments. This is a technical limitation of how "FeatureSegment" is + implemented in Flagsmith. + + :param clauses: a list of clauses from Launch Darkly's targeting rule. + :param feature: the feature to target for the segment. + :param segments_by_ld_key: a mapping from Launch Darkly segment key to Segment. Used to find right segment + from the "segmentMatch" operator + :return: a list of "FeatureSegment" operators created for each "segmentMatch" operator. + """ + + if any(clause["op"] != "segmentMatch" for clause in clauses): + for clause in clauses: + _log_error( + import_request=import_request, + error_message=f"Could not import segment clause {clause['attribute']} {clause['op']} for" + f" {feature.name} in {environment.name}: nested segment match is not supported.", + ) + return [] + + if any(clause["negate"] is True for clause in clauses): + _log_error( + import_request=import_request, + error_message=f"Negated segment match is not supported, skipping" + f" for {feature.name} in {environment.name}", + ) + return [] + + # Complex rules that allow matching segments is not allowed in Flagsmith. + # We can only emulate a single segment match by enabling the segment rule. + all_targeted_segments: list[str] = sum([clause["values"] for clause in clauses], []) + feature_states: list[FeatureState] = [] + for index, targeted_segment_key in enumerate(all_targeted_segments): + if targeted_segment_key not in segments_by_ld_key: + _log_error( + import_request=import_request, + error_message=f"Segment {targeted_segment_key} not found, skipping" + f" for {feature.name} in {environment.name}", + ) + continue + targeted_segment_name = segments_by_ld_key[targeted_segment_key].name + + # We assume segment is already created. + segment = Segment.objects.get(name=targeted_segment_name, project=project) + + feature_segment, _ = FeatureSegment.objects.update_or_create( + feature=feature, + segment=segment, + environment=environment, + priority=index, + ) + + # Enable rules by default. In LD, rules are enabled if the flag is on. + feature_state, _ = FeatureState.objects.update_or_create( + feature=feature, + feature_segment=feature_segment, + environment=environment, + defaults={"enabled": True}, + ) + + feature_states.append(feature_state) + + return feature_states + + +def _create_segment_rule_for_segment( + import_request: LaunchDarklyImportRequest, + segment: Segment, + clauses: list[Clause], +) -> SegmentRule: + """ + Create the SegmentRule for the given segment and clauses. This method doesn't handle any feature-specific + segments. Use "_create_feature_segment_from_clauses" for that. + + :param segment: the segment to create the rule for. + :param clauses: a list of clauses from Launch Darkly's segment rule. This describes which identities belong + to the given segment. + :return: the SegmentRule created for the given segment. + """ + + parent_rule, _ = SegmentRule.objects.get_or_create( + segment=segment, type=SegmentRule.ALL_RULE + ) + # TODO: Delete existing rules if parent_rule already exists. + + negated_child = None + + for clause in clauses: + _property = clause["attribute"] + operator = _ld_operator_to_flagsmith_operator(clause["op"]) + values = _convert_ld_values( + [str(value) for value in clause["values"]], clause["op"] + ) + + if operator is not None: + # Since there is no !X operation in Flagsmith, we wrap negated conditions in a none() rule. + if clause["negate"] is True: + # Create a negated child if it doesn't exist. + if negated_child is None: + negated_child = SegmentRule.objects.create( + rule=parent_rule, type=SegmentRule.NONE_RULE + ) + + target_rule = negated_child + else: + # Create a new child rule if it doesn't exist. Each child rule is "AND"ed together because + # parent_rule has type of `ALL`. Also note that each Condition added to this child rule is + # "OR"ed together. This is also how Launch Darkly works. + child_rule = SegmentRule.objects.create( + rule=parent_rule, type=SegmentRule.ANY_RULE + ) + target_rule = child_rule + + # Create a condition for each value. Each condition is "OR"ed together. + for value in values: + condition, _ = Condition.objects.update_or_create( + rule=target_rule, + property=_property, + value=value, + operator=operator, + created_with_segment=True, + ) + else: + _log_error( + import_request=import_request, + error_message=f"Can't map launch darkly operator: {clause['op']}" + f" skipping for segment: {segment.name}", + ) + + return parent_rule + + +def _create_feature_segment_from_clauses( + import_request: LaunchDarklyImportRequest, + clauses: list[Clause], + project: Project, + feature: Feature, + environment: Environment, + segments_by_ld_key: dict[str, Segment], + rule_name: str, +) -> list[FeatureState]: + """ + Create one or multiple feature-specific segment for the given clauses. Note that "segmentMatch" operator + is not fully supported. If "segmentMatch" is used, we create a feature rule for the given segment(s) instead + of a feature-specific segment. Thus, we return multiple feature states if there are multiple segments being + targeted. + + Also note that "segmentMatch" operator is not supported for nested rules. If a nested rule contains + "segmentMatch", it can't use any other targeting operators. This is because we convert "segmentMatch" into + a segment specific feature value, thus no further filter can be applied. + + :param clauses: a list of clauses from Launch Darkly's targeting rule. + :param feature: the feature to target for identities. + :param segments_by_ld_key: a mapping from Launch Darkly segment key to Segment. Used for "segmentMatch" op. + :param rule_name: the name of the rule this feature-specific segment is created for. + :return: a list of FeatureState objects for the newly created feature-specific segments. + """ + # There is no "segmentMatch" operator in flagsmith, instead we create a targeting rule for that + # specific segment. + if "segmentMatch" in [clause["op"] for clause in clauses]: + return _create_feature_segments_for_segment_match_clauses( + import_request=import_request, + clauses=clauses, + project=project, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + ) + + # Create a feature specific segment for the rule. + segment, _ = Segment.objects.update_or_create( + name=rule_name, project=project, feature=feature + ) + + # Create a targeting rule for the new feature-specific segment. + _create_segment_rule_for_segment( + import_request=import_request, + segment=segment, + clauses=clauses, + ) + + # Tie the feature and segment together. + feature_segment, _ = FeatureSegment.objects.update_or_create( + feature=feature, + segment=segment, + environment=environment, + ) + + # Enable rules by default. In LD, rules are enabled if the flag is on. + return [ + FeatureState.objects.update_or_create( + feature=feature, + feature_segment=feature_segment, + environment=environment, + defaults={"enabled": True}, + )[0] + ] + + +def _import_targets( + import_request: LaunchDarklyImportRequest, + ld_flag_config: ld_types.FeatureFlagConfig, + feature: Feature, + environment: Environment, + segments_by_ld_key: dict[str, Segment], + mv_feature_options_by_variation: dict[str, MultivariateFeatureOption], +) -> None: + """ + Import the individual targeting rules for the given Launch Darkly's feature flag. + + :param ld_flag_config: the feature flag config from Launch Darkly. + :param feature: the feature to target for identities. + :param environment: the environment to target for identities. + :param mv_feature_options_by_variation: a mapping from variation index to MultivariateFeatureOption if the + flag is multivariate. + """ + if "targets" in ld_flag_config: + # Identifiers are grouped by their variation index. So each target has the same variation index. + for target in ld_flag_config["targets"]: + # Create a segment override for those identities. This is a work-around to support individual + # targeting in local evaluation mode. + # TODO: Remove this when https://github.com/Flagsmith/flagsmith/issues/3132 is resolved. + feature_states = _create_feature_segment_from_clauses( + import_request=import_request, + clauses=[ + { + "attribute": "key", + "op": "in", + "values": target["values"], + "negate": False, + } + ], + project=feature.project, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + rule_name=f"individual-targeting-variation-{target['variation']}", + ) + + _set_imported_mv_feature_state_values( + variation_idx=str(target["variation"]), + rollout=None, + feature_states=feature_states, + mv_feature_options_by_variation=mv_feature_options_by_variation, + ) + + # Create individual identity targets. + for identifier in target["values"]: + identity, _ = Identity.objects.get_or_create( + identifier=identifier, + environment=environment, + ) + identity.update_traits( + [ + { + "trait_key": "key", + "trait_value": identifier, + } + ] + ) + + # Set identity overrides. + if len(mv_feature_options_by_variation) == 0: + FeatureState.objects.update_or_create( + feature=feature, + feature_segment=None, + environment=environment, + identity=identity, + defaults={"enabled": target["variation"] == 0}, + ) + else: + feature_state, _ = FeatureState.objects.update_or_create( + feature=feature, + feature_segment=None, + environment=environment, + identity=identity, + defaults={"enabled": True}, + ) + + mv_feature_option = mv_feature_options_by_variation[ + str(target["variation"]) + ] + MultivariateFeatureStateValue.objects.update_or_create( + feature_state=feature_state, + multivariate_feature_option=mv_feature_option, + defaults={ + "percentage_allocation": 100, + }, + ) + + if "contextTargets" in ld_flag_config and len(ld_flag_config["contextTargets"]) > 0: + if ( + sum( + [ + len(context_target["values"]) + for context_target in ld_flag_config["contextTargets"] + ] + ) + > 0 + ): + _log_error( + import_request=import_request, + error_message=f"Context targets are not supported, skipping context targets for feature" + f" {feature.name} in environment {environment.name}", + ) + + +def _set_imported_mv_feature_state_values( + variation_idx: Optional[str], + rollout: Optional[ld_types.Rollout], + feature_states: list[FeatureState], + mv_feature_options_by_variation: dict[str, MultivariateFeatureOption], +) -> None: + """ + Set the feature states and multivariate feature states for recently imported flags. + If none of 'variation_idx' and 'rollout' is set, nothing is done. If the flag is not multivariate, + nothing is done. + + :param variation_idx: the variation index to set as the control value. This is the launch darkly variation + index, not the index of the variation in Flagsmith. + :param rollout: the rollout to set as the control value coming from Launch Darkly. + :param feature_states: the feature states to set the values for. + :param mv_feature_options_by_variation: a mapping from variation index to MultivariateFeatureOption if the + flag is multivariate. + """ + + # For Multivariate flags, we need to set targeting rules for each variation. + if len(mv_feature_options_by_variation) > 0: + # For each feature state, + for feature_state in feature_states: + if variation_idx is not None: + for mv_variation in mv_feature_options_by_variation: + mv_feature_option = mv_feature_options_by_variation[mv_variation] + # We expect only one variation to be set as the control. + # Control value is set to 100% and rest is set to 0%. + MultivariateFeatureStateValue.objects.update_or_create( + feature_state=feature_state, + multivariate_feature_option=mv_feature_option, + defaults={ + "percentage_allocation": 100 + if variation_idx == mv_variation + else 0 + }, + ) + elif rollout is not None: + cumulative_rollout = rollout_baseline = 0 + for weighted_variation in rollout["variations"]: + # Find the corresponding variation value. + weight = weighted_variation["weight"] + cumulative_rollout += weight / 1000 + cumulative_rollout_rounded = round(cumulative_rollout) + + # LD has weights between 0-100,000. Flagsmith has weights between 0-100. + # While scaling down, we need to keep track of the cumulative rollout so the + # values will add up to 100%. + percentage_allocation = ( + cumulative_rollout_rounded - rollout_baseline + ) + rollout_baseline = cumulative_rollout_rounded + + mv_feature_option = mv_feature_options_by_variation[ + str(weighted_variation["variation"]) + ] + MultivariateFeatureStateValue.objects.update_or_create( + feature_state=feature_state, + multivariate_feature_option=mv_feature_option, + defaults={"percentage_allocation": percentage_allocation}, + ) + + +def _import_rules( + import_request: LaunchDarklyImportRequest, + ld_flag_config: ld_types.FeatureFlagConfig, + feature: Feature, + environment: Environment, + segments_by_ld_key: dict[str, Segment], + mv_feature_options_by_variation: dict[str, MultivariateFeatureOption], +) -> None: + """ + Import each rule in the given Launch Darkly's feature flag as a feature-specific segment in Flagsmith. + + :param ld_flag_config: the feature flag config from Launch Darkly. + :param feature: the feature to import the rules to. + :param segments_by_ld_key: a mapping from Launch Darkly segment key to Segment. Used for "segmentMatch" op. + :param mv_feature_options_by_variation: a mapping from variation index to MultivariateFeatureOption if the + flag is multivariate. Used for setting multivariate flag weights. + """ + + if "prerequisites" in ld_flag_config and len(ld_flag_config["prerequisites"]) > 0: + _log_error( + import_request=import_request, + error_message=f"Prerequisites are not supported, skipping prerequisites for feature" + f" {feature.name} in environment {environment.name}", + ) + + # For each rule in LD's flag, + if "rules" in ld_flag_config: + for rule in ld_flag_config["rules"]: + # Generate a unique and descriptive name for the rule. This name is re-used on consecutive imports + # to prevent duplicate rules. + rule_name = rule.get("description", "imported-" + rule["_id"]) + # Create the feature segment for the given rule and get the feature state objects from those + # newly created feature-specific segments. + feature_states = _create_feature_segment_from_clauses( + import_request=import_request, + clauses=rule["clauses"], + project=feature.project, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + rule_name=rule_name, + ) + + _set_imported_mv_feature_state_values( + variation_idx=rule.get("variation", None), + rollout=rule.get("rollout", None), + feature_states=feature_states, + mv_feature_options_by_variation=mv_feature_options_by_variation, + ) + + +def _create_boolean_feature_states_with_segments_identities( + import_request: LaunchDarklyImportRequest, ld_flag: ld_types.FeatureFlag, feature: Feature, environments_by_ld_environment_key: dict[str, Environment], + segments_by_ld_key: dict[str, Segment], ) -> None: for ld_environment_key, environment in environments_by_ld_environment_key.items(): ld_flag_config = ld_flag["environments"][ld_environment_key] feature_state, _ = FeatureState.objects.update_or_create( feature=feature, + feature_segment=None, environment=environment, defaults={"enabled": ld_flag_config["on"]}, ) @@ -126,11 +639,32 @@ def _create_boolean_feature_states( feature_state=feature_state, ) + # TODO: Move target and rule creation to be invoked directly from `process_import_request`. + # https://github.com/Flagsmith/flagsmith/issues/3383 + _import_targets( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation={}, + ) + _import_rules( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation={}, + ) + -def _create_string_feature_states( +def _create_string_feature_states_with_segments_identities( + import_request: LaunchDarklyImportRequest, ld_flag: ld_types.FeatureFlag, feature: Feature, environments_by_ld_environment_key: dict[str, Environment], + segments_by_ld_key: dict[str, Segment], ) -> None: variations_by_idx = { str(idx): variation for idx, variation in enumerate(ld_flag["variations"]) @@ -156,19 +690,42 @@ def _create_string_feature_states( feature_state, _ = FeatureState.objects.update_or_create( feature=feature, + feature_segment=None, environment=environment, defaults={"enabled": is_flag_on}, ) + FeatureStateValue.objects.update_or_create( feature_state=feature_state, defaults={"type": STRING, "string_value": string_value}, ) + # TODO: Move target and rule creation to be invoked directly from `process_import_request`. + # https://github.com/Flagsmith/flagsmith/issues/3383 + _import_targets( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation={}, + ) + _import_rules( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation={}, + ) + -def _create_mv_feature_states( +def _create_mv_feature_states_with_segments_identities( + import_request: LaunchDarklyImportRequest, ld_flag: ld_types.FeatureFlag, feature: Feature, environments_by_ld_environment_key: dict[str, Environment], + segments_by_ld_key: dict[str, Segment], ) -> None: variations = ld_flag["variations"] variation_values_by_idx: dict[str, str] = {} @@ -194,6 +751,7 @@ def _create_mv_feature_states( feature_state, _ = FeatureState.objects.update_or_create( feature=feature, + feature_segment=None, environment=environment, defaults={"enabled": is_flag_on}, ) @@ -239,31 +797,65 @@ def _create_mv_feature_states( defaults={"percentage_allocation": percentage_allocation}, ) + # TODO: Move target and rule creation to be invoked directly from `process_import_request`. + # https://github.com/Flagsmith/flagsmith/issues/3383 + _import_targets( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation=mv_feature_options_by_variation, + ) + _import_rules( + import_request=import_request, + ld_flag_config=ld_flag_config, + feature=feature, + environment=environment, + segments_by_ld_key=segments_by_ld_key, + mv_feature_options_by_variation=mv_feature_options_by_variation, + ) + def _get_feature_type_and_feature_state_factory( ld_flag: ld_types.FeatureFlag, ) -> Tuple[ FeatureType, - Callable[[ld_types.FeatureFlag, Feature, dict[str, Environment]], None], + Callable[ + [ + LaunchDarklyImportRequest, + ld_types.FeatureFlag, + Feature, + dict[str, Environment], + dict[str, Segment], + ], + None, + ], ]: match ld_flag["kind"]: case "multivariate" if len(ld_flag["variations"]) > 2: feature_type = MULTIVARIATE - feature_state_factory = _create_mv_feature_states + feature_state_factory = _create_mv_feature_states_with_segments_identities case "multivariate": feature_type = STANDARD - feature_state_factory = _create_string_feature_states + feature_state_factory = ( + _create_string_feature_states_with_segments_identities + ) case _: # assume boolean feature_type = STANDARD - feature_state_factory = _create_boolean_feature_states + feature_state_factory = ( + _create_boolean_feature_states_with_segments_identities + ) return feature_type, feature_state_factory def _create_feature_from_ld( + import_request: LaunchDarklyImportRequest, ld_flag: ld_types.FeatureFlag, environments_by_ld_environment_key: dict[str, Environment], tags_by_ld_tag: dict[str, Tag], + segments_by_ld_key: dict[str, Segment], project_id: int, ) -> Feature: ( @@ -291,31 +883,136 @@ def _create_feature_from_ld( feature.tags.set(tags) feature_state_factory( + import_request=import_request, ld_flag=ld_flag, feature=feature, environments_by_ld_environment_key=environments_by_ld_environment_key, + segments_by_ld_key=segments_by_ld_key, ) return feature def _create_features_from_ld( + import_request: LaunchDarklyImportRequest, ld_flags: list[ld_types.FeatureFlag], environments_by_ld_environment_key: dict[str, Environment], tags_by_ld_tag: dict[str, Tag], + segments_by_ld_key: dict[str, Segment], project_id: int, ) -> list[Feature]: return [ _create_feature_from_ld( + import_request=import_request, ld_flag=ld_flag, environments_by_ld_environment_key=environments_by_ld_environment_key, tags_by_ld_tag=tags_by_ld_tag, + segments_by_ld_key=segments_by_ld_key, project_id=project_id, ) for ld_flag in ld_flags ] +def _include_users_to_segment( + segment: Segment, + users: list[str], + negate: bool, +) -> None: + if len(users) == 0: + return + + # Find the parent rule of the segment. + parent_rule, _ = SegmentRule.objects.get_or_create( + segment=segment, type=SegmentRule.ALL_RULE + ) + + # Create a condition to match against those identities via "key" trait. + identities_string = ",".join(users) + included_rule = SegmentRule.objects.create( + rule=parent_rule, + type=SegmentRule.NONE_RULE if negate else SegmentRule.ANY_RULE, + ) + Condition.objects.update_or_create( + rule=included_rule, + property="key", + value=identities_string, + operator=constants.IN, + created_with_segment=True, + ) + + +def _create_segments_from_ld( + import_request: LaunchDarklyImportRequest, + ld_segments: list[tuple[ld_types.UserSegment, str]], + environments_by_ld_environment_key: dict[str, Environment], + tags_by_ld_tag: dict[str, Tag], + project_id: int, +) -> dict[str, Segment]: + """ + Create segments from the given Launch Darkly segments. This also creates inclusion rules for segments. + + :param ld_segments: A list of mapping from (env, segment). + :return A mapping from ld segment key to Segment itself. + """ + segments_by_ld_key = {} + for ld_segment, env in ld_segments: + if ld_segment["deleted"]: + continue + + # Make sure consecutive updates do not create the same segment. + segment, _ = Segment.objects.update_or_create( + name=_get_segment_name(ld_segment["name"], env), + project_id=project_id, + ) + + segments_by_ld_key[ld_segment["key"]] = segment + + # TODO: Tagging segments is not supported yet. https://github.com/Flagsmith/flagsmith/issues/3241 + + # Create the segment rule for the segment. + rules = ld_segment["rules"] + for rule in rules: + _create_segment_rule_for_segment( + import_request=import_request, + segment=segment, + clauses=rule["clauses"], + ) + + # Create or update identities that are mentioned in the segment. + for identifier in ld_segment["included"] + ld_segment["excluded"]: + identity, _ = Identity.objects.get_or_create( + identifier=identifier, + environment=environments_by_ld_environment_key[env], + ) + identity.update_traits( + [ + { + "trait_key": "key", + "trait_value": identifier, + } + ] + ) + + _include_users_to_segment(segment, ld_segment["included"], False) + _include_users_to_segment(segment, ld_segment["excluded"], True) + + if ( + len(ld_segment["includedContexts"]) > 0 + or len(ld_segment["excludedContexts"]) > 0 + ): + _log_error( + import_request=import_request, + error_message=f"Contexts are not supported, skipping contexts for segment: {segment.name}", + ) + + # Create an empty rule if there are no rules. This is required to create an "SegmentRule" object. + # Otherwise, UI fails to display the segment. + SegmentRule.objects.get_or_create(segment=segment, type=SegmentRule.ALL_RULE) + + return segments_by_ld_key + + def create_import_request( project: "Project", user: "FFAdminUser", @@ -330,6 +1027,7 @@ def create_import_request( status: LaunchDarklyImportStatus = { "requested_environment_count": ld_project["environments"]["totalCount"], "requested_flag_count": requested_flag_count, + "error_messages": [], } return LaunchDarklyImportRequest.objects.create( @@ -356,7 +1054,18 @@ def process_import_request( try: ld_environments = ld_client.get_environments(project_key=ld_project_key) ld_flags = ld_client.get_flags(project_key=ld_project_key) - ld_tags = ld_client.get_flag_tags() + ld_flag_tags = ld_client.get_flag_tags() + # ld_segment_tags = ld_client.get_segment_tags() + # Keyed by (segment, environment) + ld_segments: list[tuple[ld_types.UserSegment, str]] = [] + for env in ld_environments: + ld_segments_for_env = ld_client.get_segments( + project_key=ld_project_key, + environment_key=env["key"], + ) + for segment in ld_segments_for_env: + ld_segments.append((segment, env["key"])) + except RequestException as exc: _log_error( import_request=import_request, @@ -368,17 +1077,33 @@ def process_import_request( ) raise + # Create environments environments_by_ld_environment_key = _create_environments_from_ld( ld_environments=ld_environments, project_id=import_request.project_id, ) - tags_by_ld_tag = _create_tags_from_ld( - ld_tags=ld_tags, + + # Create segments using `ld_segment_tags` + # TODO populate with LD tags when https://github.com/Flagsmith/flagsmith/issues/3241 is done + segment_tags_by_ld_tag = {} + segments_by_ld_key = _create_segments_from_ld( + import_request=import_request, + ld_segments=ld_segments, + environments_by_ld_environment_key=environments_by_ld_environment_key, + tags_by_ld_tag=segment_tags_by_ld_tag, + project_id=import_request.project_id, + ) + + # Create flags + flag_tags_by_ld_tag = _create_tags_from_ld( + ld_tags=ld_flag_tags, project_id=import_request.project_id, ) _create_features_from_ld( + import_request=import_request, ld_flags=ld_flags, environments_by_ld_environment_key=environments_by_ld_environment_key, - tags_by_ld_tag=tags_by_ld_tag, + tags_by_ld_tag=flag_tags_by_ld_tag, + segments_by_ld_key=segments_by_ld_key, project_id=import_request.project_id, ) diff --git a/api/integrations/launch_darkly/types.py b/api/integrations/launch_darkly/types.py index 572d93ae7ec9..33530e42d18c 100644 --- a/api/integrations/launch_darkly/types.py +++ b/api/integrations/launch_darkly/types.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: -# timestamp: 2023-09-28T03:54:47+00:00 +# timestamp: 2024-01-04T14:34:37+00:00 from __future__ import annotations @@ -44,6 +44,15 @@ class FlagConfigEvaluation(TypedDict): contextKinds: NotRequired[list[str]] +class FlagConfigMigrationSettingsRep(TypedDict): + checkRatio: NotRequired[int] + + +class FlagMigrationSettingsRep(TypedDict): + contextKind: NotRequired[str] + stageCount: NotRequired[int] + + class ForbiddenErrorRep(TypedDict): code: str message: str @@ -60,8 +69,8 @@ class Link(TypedDict): class MaintainerTeam(TypedDict): - key: NotRequired[str] - name: NotRequired[str] + key: str + name: str _links: NotRequired[dict[str, Link]] @@ -79,6 +88,11 @@ class MethodNotAllowedErrorRep(TypedDict): message: str +class MetricEventDefaultRep(TypedDict): + disabled: NotRequired[bool] + value: NotRequired[float] + + class Modification(TypedDict): date: NotRequired[str] @@ -101,6 +115,20 @@ class RateLimitedErrorRep(TypedDict): message: str +SegmentId = str + + +class SegmentTarget(TypedDict): + values: NotRequired[list[str]] + contextKind: NotRequired[str] + + +class TagCollection(TypedDict): + items: list[str] + _links: dict[str, Link] + totalCount: NotRequired[int] + + class Target(TypedDict): values: list[str] variation: int @@ -197,12 +225,13 @@ class Environment(TypedDict): confirmChanges: bool tags: list[str] approvalSettings: NotRequired[ApprovalSettings] + critical: bool class Environments(TypedDict): _links: NotRequired[dict[str, Link]] totalCount: NotRequired[int] - items: NotRequired[list[Environment]] + items: list[Environment] class ExperimentEnabledPeriodRep(TypedDict): @@ -216,6 +245,13 @@ class ExperimentEnvironmentSettingRep(TypedDict): enabledPeriods: NotRequired[list[ExperimentEnabledPeriodRep]] +class FlagListingRep(TypedDict): + name: str + key: str + _links: NotRequired[dict[str, Link]] + _site: NotRequired[Link] + + class FlagSummary(TypedDict): variations: AllVariationsSummary prerequisites: int @@ -250,6 +286,25 @@ class Rule(TypedDict): ref: NotRequired[str] +class SegmentMetadata(TypedDict): + envId: NotRequired[str] + segmentId: NotRequired[SegmentId] + version: NotRequired[int] + includedCount: NotRequired[int] + excludedCount: NotRequired[int] + lastModified: NotRequired[UnixMillis] + deleted: NotRequired[bool] + + +class UserSegmentRule(TypedDict): + _id: NotRequired[str] + clauses: list[Clause] + weight: NotRequired[int] + rolloutContextKind: NotRequired[str] + bucketBy: NotRequired[str] + description: NotRequired[str] + + class VariationOrRolloutRep(TypedDict): variation: NotRequired[int] rollout: NotRequired[Rollout] @@ -286,10 +341,12 @@ class FeatureFlagConfig(TypedDict): _debugEventsUntilDate: NotRequired[UnixMillis] _summary: NotRequired[FlagSummary] evaluation: NotRequired[FlagConfigEvaluation] + migrationSettings: NotRequired[FlagConfigMigrationSettingsRep] class MetricListingRep(TypedDict): experimentCount: NotRequired[int] + metricGroupCount: NotRequired[int] _id: str key: str name: str @@ -309,6 +366,42 @@ class MetricListingRep(TypedDict): unit: NotRequired[str] eventKey: NotRequired[str] randomizationUnits: NotRequired[list[str]] + unitAggregationType: NotRequired[Literal["average", "sum"]] + analysisType: NotRequired[Literal["mean", "percentile"]] + percentileValue: NotRequired[int] + eventDefault: NotRequired[MetricEventDefaultRep] + + +class UserSegment(TypedDict): + name: str + description: NotRequired[str] + tags: list[str] + creationDate: UnixMillis + lastModifiedDate: UnixMillis + key: str + included: NotRequired[list[str]] + excluded: NotRequired[list[str]] + includedContexts: NotRequired[list[SegmentTarget]] + excludedContexts: NotRequired[list[SegmentTarget]] + _links: dict[str, Link] + rules: list[UserSegmentRule] + version: int + deleted: bool + _access: NotRequired[Access] + _flags: NotRequired[list[FlagListingRep]] + unbounded: NotRequired[bool] + unboundedContextKind: NotRequired[str] + generation: int + _unboundedMetadata: NotRequired[SegmentMetadata] + _external: NotRequired[str] + _externalLink: NotRequired[str] + _importInProgress: NotRequired[bool] + + +class UserSegments(TypedDict): + items: list[UserSegment] + _links: dict[str, Link] + totalCount: int class LegacyExperimentRep(TypedDict): @@ -345,7 +438,11 @@ class FeatureFlag(TypedDict): customProperties: CustomProperties archived: bool archivedDate: NotRequired[UnixMillis] + deprecated: bool + deprecatedDate: NotRequired[UnixMillis] defaults: NotRequired[Defaults] + _purpose: NotRequired[str] + migrationSettings: NotRequired[FlagMigrationSettingsRep] environments: dict[str, FeatureFlagConfig] diff --git a/api/ld-openapi-filter.yaml b/api/ld-openapi-filter.yaml index 13520e0e0b21..9c13a1cafc37 100644 --- a/api/ld-openapi-filter.yaml +++ b/api/ld-openapi-filter.yaml @@ -3,6 +3,8 @@ inverseOperationIds: - getProject - getEnvironmentsByProject - getFeatureFlags + - getTags + - getSegments unusedComponents: - schemas - parameters diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json index 84e12c46be45..54ea2667cbcb 100644 --- a/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json @@ -98,55 +98,5 @@ "serviceConfig": {}, "requiredApprovalTags": [] } - }, - { - "_links": { - "analytics": { - "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142c.js", - "type": "text/html" - }, - "apiKey": { - "href": "/api/v2/projects/test-project-key/environments/production/apiKey", - "type": "application/json" - }, - "mobileKey": { - "href": "/api/v2/projects/test-project-key/environments/production/mobileKey", - "type": "application/json" - }, - "self": { - "href": "/api/v2/projects/test-project-key/environments/production", - "type": "application/json" - }, - "snippet": { - "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142c.js", - "type": "text/html" - } - }, - "_id": "64f6f21653c76f12626d142c", - "_pubnub": { - "channel": "1dbc7bd143e2711f6bdb9a242a4f99d34e26e2d7767e2254ca64a6293336af25", - "cipherKey": "9c2ab94ab61d603539829701814dfeeed9cf37f05ec14012e61da008fa924bf9" - }, - "key": "production", - "name": "Production", - "apiKey": "sdk-d222b487-4486-4c11-95b7-d3e8fadf8ea9", - "mobileKey": "mob-63341f83-9943-4fe6-9ca5-26ee4bc4e4a9", - "color": "417505", - "defaultTtl": 0, - "secureMode": false, - "defaultTrackEvents": false, - "requireComments": true, - "confirmChanges": true, - "tags": [], - "approvalSettings": { - "required": false, - "bypassApprovalsForPendingChanges": false, - "minNumApprovals": 1, - "canReviewOwnRequest": false, - "canApplyDeclinedChanges": true, - "serviceKind": "launchdarkly", - "serviceConfig": {}, - "requiredApprovalTags": [] - } } -] \ No newline at end of file +] diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json index d7111c2cd754..22245da752c9 100644 --- a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json @@ -651,5 +651,1088 @@ "value": false } ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/TEST_TARGETED_CONTEXT", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 2, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1704230740154, + "customProperties": {}, + "defaults": { + "offVariation": 3, + "onVariation": 3 + }, + "deprecated": false, + "description": "", + "environments": { + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/TEST_TARGETED_CONTEXT", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "1": { + "contextTargets": 0, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "2": { + "contextTargets": 0, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "3": { + "contextTargets": 0, + "isFallthrough": true, + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "contextTargets": [], + "fallthrough": { + "variation": 3 + }, + "lastModified": 1704730475810, + "offVariation": 3, + "on": true, + "prerequisites": [], + "rules": [ + { + "_id": "a132f4aa-ad51-43c6-8d03-f18d6a5b205d", + "clauses": [ + { + "_id": "2dd8bad3-b0f2-446f-ad32-3b106da30cac", + "attribute": "foo", + "contextKind": "user", + "negate": false, + "op": "startsWith", + "values": [ + "abc", + "dogac" + ] + } + ], + "ref": "90a06678-943a-4fa3-9e6d-fa7438ec1650", + "trackEvents": false, + "variation": 1 + }, + { + "_id": "c034ec70-fcb3-4c15-9bea-b9fa0b341b4f", + "clauses": [ + { + "_id": "e4cf5790-6212-4f41-bef4-fcbdb8b4a6a6", + "attribute": "p2", + "contextKind": "user", + "negate": false, + "op": "in", + "values": [ + "abc", + "dogac" + ] + } + ], + "ref": "4a1cbae4-dcf8-4c0e-a79a-44152385b0bb", + "trackEvents": false, + "variation": 2 + }, + { + "_id": "61853466-1677-4ade-9c7f-735788dc98eb", + "clauses": [ + { + "_id": "403bc17a-aaca-41bf-add6-419c5641bd79", + "attribute": "p1", + "contextKind": "user", + "negate": false, + "op": "endsWith", + "values": [ + "bar" + ] + }, + { + "_id": "82436c5f-85eb-425d-a7c9-fc44ca467fb9", + "attribute": "p2", + "contextKind": "user", + "negate": true, + "op": "contains", + "values": [ + "forbidden", + "words" + ] + } + ], + "description": "Reverted And", + "ref": "4aaccd97-6288-42dd-9171-f91f2d804ba7", + "trackEvents": false, + "variation": 0 + }, + { + "_id": "9688e67a-87f1-4a24-962c-157c4421303e", + "clauses": [ + { + "_id": "cc992651-29fd-417a-8f4b-edba27e7e26e", + "attribute": "p1", + "contextKind": "user", + "negate": false, + "op": "lessThanOrEqual", + "values": [ + 5 + ] + }, + { + "_id": "2742358a-d236-4061-9fe2-75bf998529cf", + "attribute": "p2", + "contextKind": "user", + "negate": false, + "op": "greaterThan", + "values": [ + 1 + ] + } + ], + "description": "Regular And", + "ref": "7784e9ea-0505-432c-ad29-3806585cb117", + "trackEvents": false, + "variation": 1 + }, + { + "_id": "97ca7f20-a8b2-431a-a425-48f3bc28c6df", + "clauses": [ + { + "_id": "38ef7979-b516-4ed3-9e61-41df7508955c", + "attribute": "p1", + "contextKind": "user", + "negate": true, + "op": "in", + "values": ["this", "that"] + } + ], + "description": "Just Not", + "ref": "0455939e-4c46-49de-b107-ea8b246a79dd", + "trackEvents": false, + "variation": 2 + } + ], + "salt": "9d5c8d03f09f441fb265a387cacf0834", + "sel": "aa8d74daed9a40788aff0c1b37ec31ad", + "targets": [], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 14 + }, + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/test/features/TEST_TARGETED_CONTEXT", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "1": { + "contextTargets": 0, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "2": { + "contextTargets": 0, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "3": { + "contextTargets": 0, + "isFallthrough": true, + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "contextTargets": [], + "fallthrough": { + "variation": 3 + }, + "lastModified": 1704730475810, + "offVariation": 3, + "on": true, + "prerequisites": [], + "rules": [ + { + "_id": "a132f4aa-ad51-43c6-8d03-f18d6a5b205d", + "clauses": [ + { + "_id": "2dd8bad3-b0f2-446f-ad32-3b106da30cac", + "attribute": "foo", + "contextKind": "user", + "negate": false, + "op": "startsWith", + "values": [ + "abc", + "dogac" + ] + } + ], + "ref": "90a06678-943a-4fa3-9e6d-fa7438ec1650", + "trackEvents": false, + "variation": 1 + }, + { + "_id": "c034ec70-fcb3-4c15-9bea-b9fa0b341b4f", + "clauses": [ + { + "_id": "e4cf5790-6212-4f41-bef4-fcbdb8b4a6a6", + "attribute": "p2", + "contextKind": "user", + "negate": false, + "op": "in", + "values": [ + "abc", + "dogac" + ] + } + ], + "ref": "4a1cbae4-dcf8-4c0e-a79a-44152385b0bb", + "trackEvents": false, + "variation": 2 + } + ], + "salt": "9d5c8d03f09f441fb265a387cacf0834", + "sel": "aa8d74daed9a40788aff0c1b37ec31ad", + "targets": [], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 14 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "TEST_TARGETED_CONTEXT", + "kind": "multivariate", + "maintainerId": "60dc82f864c60f258d31fe60", + "name": "TEST_TARGETED_CONTEXT", + "tags": [], + "temporary": false, + "variationJsonSchema": null, + "variations": [ + { + "_id": "dbb0e794-7ea0-40af-af9a-a7c45b9c414b", + "name": "Foo", + "value": 1 + }, + { + "_id": "99b6ed87-39c2-4d06-a1cd-26cf327d1a91", + "name": "Bar", + "value": 2 + }, + { + "_id": "b89ad09b-fa93-4a3a-bb1c-e98af8da0c83", + "name": "Zoo", + "value": 3 + }, + { + "_id": "b5199483-5ef4-430c-acab-f9c89ad9451c", + "name": "Default", + "value": -1 + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/TEST_INDIVIDUAL_TARGET", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1704301985357, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "deprecated": false, + "description": "", + "environments": { + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/TEST_INDIVIDUAL_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 1 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 1 + } + } + }, + "archived": false, + "contextTargets": [ + { + "contextKind": "user", + "values": [], + "variation": 0 + }, + { + "contextKind": "user", + "values": [], + "variation": 1 + } + ], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704302011358, + "offVariation": 1, + "on": false, + "prerequisites": [], + "rules": [], + "salt": "2d3031f4e64c40648454778c6f4604c5", + "sel": "b1662f133b8044a68533e821c5e2fd0c", + "targets": [ + { + "contextKind": "user", + "values": [ + "user-1005" + ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ + "user-10006" + ], + "variation": 1 + } + ], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + }, + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/test/features/TEST_INDIVIDUAL_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 1 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 1 + } + } + }, + "archived": false, + "contextTargets": [ + { + "contextKind": "user", + "values": [], + "variation": 0 + }, + { + "contextKind": "user", + "values": [], + "variation": 1 + } + ], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704302011358, + "offVariation": 1, + "on": false, + "prerequisites": [], + "rules": [], + "salt": "2d3031f4e64c40648454778c6f4604c5", + "sel": "b1662f133b8044a68533e821c5e2fd0c", + "targets": [ + { + "contextKind": "user", + "values": [ + "user-1005" + ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ + "user-10006" + ], + "variation": 1 + } + ], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "TEST_INDIVIDUAL_TARGET", + "kind": "boolean", + "maintainerId": "60dc82f864c60f258d31fe60", + "name": "TEST_INDIVIDUAL_TARGET", + "tags": [], + "temporary": false, + "variationJsonSchema": null, + "variations": [ + { + "_id": "cb2ad507-e2cd-457f-86aa-1e0d167fe813", + "name": "Enabled", + "value": true + }, + { + "_id": "d260207e-5ea3-4fba-ada4-d1731cf9c6d3", + "name": "Disabled", + "value": false + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/TEST_SEGMENT_TARGET", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1704302028410, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "deprecated": false, + "description": "", + "environments": { + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/TEST_SEGMENT_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + } + } + }, + "archived": false, + "contextTargets": [], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704731410003, + "offVariation": 1, + "on": true, + "prerequisites": [], + "rules": [ + { + "_id": "dca5eadf-851e-4696-bb45-0fcce26944ba", + "clauses": [ + { + "_id": "9a145ec9-5caa-4518-83b8-82796b58cbdf", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "user-list" + ] + } + ], + "ref": "4d09daad-1642-4817-901a-dbebe9b612d8", + "trackEvents": false, + "variation": 0 + }, + { + "_id": "d866a669-b439-4506-9ae6-83ea0e30ea38", + "clauses": [ + { + "_id": "2e357024-9f04-4086-bb07-77eee8f3147f", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "dynamic-list", + "dynamic-list-2" + ] + } + ], + "ref": "7cc2a75d-311c-4d9a-849f-33f148b1e27c", + "trackEvents": false, + "variation": 1 + } + ], + "salt": "5060e69d01da4133a17c29e2edad4695", + "sel": "b224656f90014477920634bf6f50b19e", + "targets": [], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 5 + }, + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/test/features/TEST_SEGMENT_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + } + } + }, + "archived": false, + "contextTargets": [], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704731410003, + "offVariation": 1, + "on": true, + "prerequisites": [], + "rules": [ + { + "_id": "dca5eadf-851e-4696-bb45-0fcce26944ba", + "clauses": [ + { + "_id": "9a145ec9-5caa-4518-83b8-82796b58cbdf", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "user-list" + ] + } + ], + "ref": "4d09daad-1642-4817-901a-dbebe9b612d8", + "trackEvents": false, + "variation": 0 + }, + { + "_id": "d866a669-b439-4506-9ae6-83ea0e30ea38", + "clauses": [ + { + "_id": "2e357024-9f04-4086-bb07-77eee8f3147f", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "dynamic-list", + "dynamic-list-2" + ] + } + ], + "ref": "7cc2a75d-311c-4d9a-849f-33f148b1e27c", + "trackEvents": false, + "variation": 1 + } + ], + "salt": "5060e69d01da4133a17c29e2edad4695", + "sel": "b224656f90014477920634bf6f50b19e", + "targets": [], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 5 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "TEST_SEGMENT_TARGET", + "kind": "boolean", + "maintainerId": "60dc82f864c60f258d31fe60", + "name": "TEST_SEGMENT_TARGET", + "tags": [], + "temporary": false, + "variationJsonSchema": null, + "variations": [ + { + "_id": "63293a52-0462-42de-a531-6154053f4114", + "name": "Enabled", + "value": true + }, + { + "_id": "9c0e62cf-8eb8-497e-97bb-2fa26c41feb3", + "name": "Disabled", + "value": false + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/TEST_COMBINED_TARGET", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1704731583604, + "customProperties": { }, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "deprecated": false, + "description": "", + "environments": { + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/TEST_COMBINED_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 2, + "targets": 1 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "2": { + "contextTargets": 0, + "nullRules": 1, + "rules": 1, + "targets": 1 + } + } + }, + "archived": false, + "contextTargets": [ + { + "contextKind": "user", + "values": [ ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ ], + "variation": 2 + } + ], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704731702475, + "offVariation": 1, + "on": true, + "prerequisites": [ ], + "rules": [ + { + "_id": "23b14914-3136-40f5-a64a-cdb3110a5277", + "clauses": [ + { + "_id": "8aae5525-86e8-4e39-895a-2f1681d484e7", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "user-list" + ] + } + ], + "ref": "b820af6a-08dc-40a0-bf3c-538b86512858", + "trackEvents": false, + "variation": 0 + }, + { + "_id": "56725db6-3d2a-4ed6-a2a1-60ef94ac62d5", + "clauses": [ + { + "_id": "053eb4a5-e103-4d60-a89a-9e47e308dd82", + "attribute": "p1", + "contextKind": "user", + "negate": false, + "op": "in", + "values": [ + "a", + "b", + "c" + ] + } + ], + "ref": "4007fe69-9745-4efe-b46a-899203df4a46", + "rollout": { + "contextKind": "user", + "variations": [ + { + "variation": 0, + "weight": 50000 + }, + { + "variation": 1, + "weight": 50000 + }, + { + "variation": 2, + "weight": 0 + } + ] + }, + "trackEvents": false + } + ], + "salt": "62d11782290041459b93245c89eafcfc", + "sel": "6c19c49f3b0f454abedf5f87b9723f0b", + "targets": [ + { + "contextKind": "user", + "values": [ + "user1" + ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ + "user2" + ], + "variation": 2 + } + ], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + }, + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/test/features/TEST_COMBINED_TARGET", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "contextTargets": 0, + "isFallthrough": true, + "nullRules": 0, + "rules": 2, + "targets": 1 + }, + "1": { + "contextTargets": 0, + "isOff": true, + "nullRules": 0, + "rules": 1, + "targets": 0 + }, + "2": { + "contextTargets": 0, + "nullRules": 1, + "rules": 1, + "targets": 1 + } + } + }, + "archived": false, + "contextTargets": [ + { + "contextKind": "user", + "values": [ ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ ], + "variation": 2 + } + ], + "fallthrough": { + "variation": 0 + }, + "lastModified": 1704731702475, + "offVariation": 1, + "on": true, + "prerequisites": [ ], + "rules": [ + { + "_id": "23b14914-3136-40f5-a64a-cdb3110a5277", + "clauses": [ + { + "_id": "8aae5525-86e8-4e39-895a-2f1681d484e7", + "attribute": "segmentMatch", + "contextKind": "user", + "negate": false, + "op": "segmentMatch", + "values": [ + "user-list" + ] + } + ], + "ref": "b820af6a-08dc-40a0-bf3c-538b86512858", + "trackEvents": false, + "variation": 0 + }, + { + "_id": "56725db6-3d2a-4ed6-a2a1-60ef94ac62d5", + "clauses": [ + { + "_id": "053eb4a5-e103-4d60-a89a-9e47e308dd82", + "attribute": "p1", + "contextKind": "user", + "negate": false, + "op": "in", + "values": [ + "a", + "b", + "c" + ] + } + ], + "ref": "4007fe69-9745-4efe-b46a-899203df4a46", + "rollout": { + "contextKind": "user", + "variations": [ + { + "variation": 0, + "weight": 50000 + }, + { + "variation": 1, + "weight": 50000 + }, + { + "variation": 2, + "weight": 0 + } + ] + }, + "trackEvents": false + } + ], + "salt": "62d11782290041459b93245c89eafcfc", + "sel": "6c19c49f3b0f454abedf5f87b9723f0b", + "targets": [ + { + "contextKind": "user", + "values": [ + "user1" + ], + "variation": 0 + }, + { + "contextKind": "user", + "values": [ + "user2" + ], + "variation": 2 + } + ], + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [ ] + }, + "goalIds": [ ], + "includeInSnippet": false, + "key": "TEST_COMBINED_TARGET", + "kind": "multivariate", + "maintainerId": "60dc82f864c60f258d31fe60", + "name": "TEST_COMBINED_TARGET", + "tags": [ ], + "temporary": false, + "variationJsonSchema": null, + "variations": [ + { + "_id": "a81d4ad4-6dd1-4d47-af8d-ebaf2eef1207", + "name": "Foo", + "value": "Foo" + }, + { + "_id": "0f1da2cd-b7a3-4caa-a52a-603060e7034e", + "name": "Bar", + "value": "Bar" + }, + { + "_id": "6ee4e91b-7b1f-4da1-a7bc-5f0eaca1c41e", + "name": "Zoo", + "value": "Zoo" + } + ] } -] \ No newline at end of file +] diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_segments.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_segments.json new file mode 100644 index 000000000000..215cd985e435 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_segments.json @@ -0,0 +1,159 @@ +[ + { + "name": "User List", + "tags": [], + "creationDate": 1704302060324, + "lastModifiedDate": 1704302081521, + "key": "user-list", + "included": [ + "user-102", + "user-101" + ], + "excluded": [ + "user-103" + ], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { + "href": "/api/v2/segments/default/test", + "type": "application/json" + }, + "self": { + "href": "/api/v2/segments/default/test/user-list", + "type": "application/json" + }, + "site": { + "href": "/default/test/segments/user-list", + "type": "text/html" + } + }, + "rules": [], + "version": 2, + "deleted": false, + "generation": 1 + }, + { + "name": "Dynamic List", + "tags": [], + "creationDate": 1704302090567, + "lastModifiedDate": 1704302154463, + "key": "dynamic-list", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { + "href": "/api/v2/segments/default/test", + "type": "application/json" + }, + "self": { + "href": "/api/v2/segments/default/test/dynamic-list", + "type": "application/json" + }, + "site": { + "href": "/default/test/segments/dynamic-list", + "type": "text/html" + } + }, + "rules": [ + { + "_id": "3405f9e2-7613-4092-b453-1b10db1bc184", + "clauses": [ + { + "_id": "43aaaae8-fc33-4edb-a954-c01bb53597e8", + "attribute": "email", + "op": "endsWith", + "values": [ + "@gmail.com" + ], + "contextKind": "user", + "negate": false + } + ], + "rolloutContextKind": "user" + } + ], + "version": 2, + "deleted": false, + "generation": 1 + }, + { + "name": "Dynamic List 2", + "tags": [], + "creationDate": 1704731242596, + "lastModifiedDate": 1704731387257, + "key": "dynamic-list-2", + "included": [ + "foo" + ], + "excluded": [ + "bar" + ], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { + "href": "/api/v2/segments/default/test", + "type": "application/json" + }, + "self": { + "href": "/api/v2/segments/default/test/dynamic-list-2", + "type": "application/json" + }, + "site": { + "href": "/default/test/segments/dynamic-list-2", + "type": "text/html" + } + }, + "rules": [ + { + "_id": "9ea1bfc9-91c5-4188-a725-8af69730cd11", + "clauses": [ + { + "_id": "294210ba-b6cd-4759-beb3-232b475930a9", + "attribute": "p1", + "op": "in", + "values": [ + 1, + 2 + ], + "contextKind": "user", + "negate": false + }, + { + "_id": "a156f6dd-1ecb-411c-b308-5b9d3428815a", + "attribute": "p2", + "op": "semVerGreaterThan", + "values": [ + "1.0.0" + ], + "contextKind": "user", + "negate": false + } + ], + "rolloutContextKind": "user" + }, + { + "_id": "7cdd28e2-feae-4a6e-8f87-ce00ccb1ce93", + "clauses": [ + { + "_id": "a316fd6a-1e0b-4ed0-a5db-c71868ecb691", + "attribute": "p3", + "op": "matches", + "values": [ + "foo[0-9]{0,1}" + ], + "contextKind": "user", + "negate": false + } + ], + "rolloutContextKind": "user" + } + ], + "version": 2, + "deleted": false, + "generation": 1 + } +] diff --git a/api/tests/unit/integrations/launch_darkly/conftest.py b/api/tests/unit/integrations/launch_darkly/conftest.py index 15b77ad2c93f..23fc72bc5aaa 100644 --- a/api/tests/unit/integrations/launch_darkly/conftest.py +++ b/api/tests/unit/integrations/launch_darkly/conftest.py @@ -30,12 +30,13 @@ def ld_client_mock(mocker: MockerFixture) -> MagicMock: "get_project": "client_responses/get_project.json", "get_environments": "client_responses/get_environments.json", "get_flags": "client_responses/get_flags.json", + "get_segments": "client_responses/get_segments.json", }.items(): getattr(ld_client_mock, method_name).return_value = json.load( open(join(dirname(abspath(__file__)), response_data_path)) ) - ld_client_mock.get_flag_count.return_value = 5 + ld_client_mock.get_flag_count.return_value = 9 ld_client_mock.get_flag_tags.return_value = ["testtag", "testtag2"] return ld_client_mock diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py index c5cbbacca382..284f6f8b5a64 100644 --- a/api/tests/unit/integrations/launch_darkly/test_services.py +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -2,8 +2,11 @@ import pytest from django.core import signing +from flag_engine.segments import constants as segment_constants from requests.exceptions import HTTPError, RequestException, Timeout +from environments.identities.models import Identity +from environments.identities.traits.models import Trait from environments.models import Environment from features.models import Feature, FeatureState from integrations.launch_darkly.models import LaunchDarklyImportRequest @@ -13,6 +16,7 @@ ) from projects.models import Project from projects.tags.models import Tag +from segments.models import Condition, Segment, SegmentRule from users.models import FFAdminUser @@ -43,7 +47,8 @@ def test_create_import_request__return_expected( assert result.status == { "requested_environment_count": 2, - "requested_flag_count": 5, + "requested_flag_count": 9, + "error_messages": [], } assert signing.loads(result.ld_token, salt=expected_salt) == ld_token assert result.ld_project_key == ld_project_key @@ -84,7 +89,7 @@ def test_process_import_request__api_error__expected_status( assert import_request.completed_at assert import_request.ld_token == "" assert import_request.status["result"] == "failure" - assert import_request.status["error_message"] == expected_error_message + assert import_request.status["error_messages"] == [expected_error_message] def test_process_import_request__success__expected_status( @@ -108,14 +113,24 @@ def test_process_import_request__success__expected_status( # Feature names are correct. assert list( Feature.objects.filter(project=project).values_list("name", flat=True) - ) == ["flag1", "flag2_value", "flag3_multivalue", "flag4_multivalue", "flag5"] + ) == [ + "flag1", + "flag2_value", + "flag3_multivalue", + "flag4_multivalue", + "flag5", + "TEST_TARGETED_CONTEXT", + "TEST_INDIVIDUAL_TARGET", + "TEST_SEGMENT_TARGET", + "TEST_COMBINED_TARGET", + ] # Tags are created and set as expected. - assert list(Tag.objects.filter(project=project).values_list("label", "color")) == [ + assert set(Tag.objects.filter(project=project).values_list("label", "color")) == { ("testtag", "#3d4db6"), ("testtag2", "#3d4db6"), ("Imported", "#3d4db6"), - ] + } assert set( Feature.objects.filter(project=project).values_list("name", "tags__label") ) == { @@ -126,6 +141,10 @@ def test_process_import_request__success__expected_status( ("flag5", "testtag"), ("flag5", "Imported"), ("flag5", "testtag2"), + ("TEST_TARGETED_CONTEXT", "Imported"), + ("TEST_INDIVIDUAL_TARGET", "Imported"), + ("TEST_SEGMENT_TARGET", "Imported"), + ("TEST_COMBINED_TARGET", "Imported"), } # Standard feature states have expected values. @@ -221,3 +240,299 @@ def test_process_import_request__success__expected_status( # Tags are imported correctly. tagged_feature = Feature.objects.get(project=project, name="flag5") [tag.label for tag in tagged_feature.tags.all()] == ["testtag", "testtag2"] + + +def test_process_import_request__segments_imported( + project: Project, + import_request: LaunchDarklyImportRequest, +): + # When + process_import_request(import_request) + + # Then + segments = Segment.objects.filter(project=project, feature_id=None) + + assert set(segments.values_list("name", flat=True)) == { + # Segments + "User List (Override for test)", + "User List (Override for production)", + "Dynamic List (Override for test)", + "Dynamic List (Override for production)", + "Dynamic List 2 (Override for test)", + "Dynamic List 2 (Override for production)", + } + + # Tests for "Dynamic List (Override for test)" + dynamic_list_test_segment = Segment.objects.get( + name="Dynamic List (Override for test)" + ) + dynamic_list_test_segment_rule = SegmentRule.objects.get( + segment=dynamic_list_test_segment + ) + # Parents are always "ALL" rules. + assert dynamic_list_test_segment_rule.type == SegmentRule.ALL_RULE + + dynamic_list_test_segment_subrules = SegmentRule.objects.filter( + rule=dynamic_list_test_segment_rule + ) + assert dynamic_list_test_segment_subrules.count() == 1 + # UI needs to have subrules as `ANY_RULE` to display properly. + assert list(dynamic_list_test_segment_subrules)[0].type == SegmentRule.ANY_RULE + + dynamic_list_test_segment_subrule_conditions = Condition.objects.filter( + rule=dynamic_list_test_segment_subrules[0] + ) + assert set( + dynamic_list_test_segment_subrule_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("email", segment_constants.REGEX, ".*@gmail\\.com"), + } + + # Tests for "Dynamic List 2 (Override for production)" + dynamic_list_2_production_segment = Segment.objects.get( + name="Dynamic List 2 (Override for production)" + ) + dynamic_list_2_production_segment_rule = SegmentRule.objects.get( + segment=dynamic_list_2_production_segment + ) + # Parents are always "ALL" rules. + assert dynamic_list_2_production_segment_rule.type == SegmentRule.ALL_RULE + + dynamic_list_2_production_segment_subrules = SegmentRule.objects.filter( + rule=dynamic_list_2_production_segment_rule + ) + assert dynamic_list_2_production_segment_subrules.count() == 5 + # UI needs to have subrules as `ANY_RULE` to display properly. + assert ( + list(dynamic_list_2_production_segment_subrules)[0].type == SegmentRule.ANY_RULE + ) + assert ( + list(dynamic_list_2_production_segment_subrules)[1].type == SegmentRule.ANY_RULE + ) + assert ( + list(dynamic_list_2_production_segment_subrules)[2].type == SegmentRule.ANY_RULE + ) + + dynamic_list_2_production_segment_subrule_0_conditions = Condition.objects.filter( + rule=dynamic_list_2_production_segment_subrules[0] + ) + + assert set( + dynamic_list_2_production_segment_subrule_0_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("p1", segment_constants.IN, "1,2"), + } + + dynamic_list_2_production_segment_subrule_1_conditions = Condition.objects.filter( + rule=dynamic_list_2_production_segment_subrules[1] + ) + assert set( + dynamic_list_2_production_segment_subrule_1_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("p2", segment_constants.GREATER_THAN, "1.0.0:semver"), + } + + dynamic_list_2_production_segment_subrule_2_conditions = Condition.objects.filter( + rule=dynamic_list_2_production_segment_subrules[2] + ) + assert set( + dynamic_list_2_production_segment_subrule_2_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("p3", segment_constants.REGEX, "foo[0-9]{0,1}"), + } + + # Include individual users + assert ( + list(dynamic_list_2_production_segment_subrules)[3].type == SegmentRule.ANY_RULE + ) + dynamic_list_2_production_segment_subrule_3_conditions = Condition.objects.filter( + rule=dynamic_list_2_production_segment_subrules[3] + ) + assert set( + dynamic_list_2_production_segment_subrule_3_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("key", segment_constants.IN, "foo"), + } + + # Exclude individual users + assert ( + list(dynamic_list_2_production_segment_subrules)[4].type + == SegmentRule.NONE_RULE + ) + dynamic_list_2_production_segment_subrule_4_conditions = Condition.objects.filter( + rule=dynamic_list_2_production_segment_subrules[4] + ) + assert set( + dynamic_list_2_production_segment_subrule_4_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("key", segment_constants.IN, "bar"), + } + + # User list segments + user_list_test_segment = Segment.objects.get(name="User List (Override for test)") + user_list_test_segment_rule = SegmentRule.objects.get( + segment=user_list_test_segment + ) + # Parents are always "ALL" rules. + assert user_list_test_segment_rule.type == SegmentRule.ALL_RULE + + user_list_test_segment_subrules = SegmentRule.objects.filter( + rule=user_list_test_segment_rule + ) + assert user_list_test_segment_subrules.count() == 2 + assert list(user_list_test_segment_subrules)[0].type == SegmentRule.ANY_RULE + user_list_test_segment_subrule_0_conditions = Condition.objects.filter( + rule=user_list_test_segment_subrules[0] + ) + assert set( + user_list_test_segment_subrule_0_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("key", segment_constants.IN, "user-102,user-101"), + } + + assert list(user_list_test_segment_subrules)[1].type == SegmentRule.NONE_RULE + user_list_test_segment_subrule_1_conditions = Condition.objects.filter( + rule=user_list_test_segment_subrules[1] + ) + assert set( + user_list_test_segment_subrule_1_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("key", segment_constants.IN, "user-103"), + } + + identifies_created = set( + Identity.objects.filter(environment__project=project).values_list( + "identifier", flat=True + ) + ) + + assert identifies_created == { + "bar", + "user-10006", + "user-102", + "user2", + "user-103", + "user-101", + "foo", + "user1", + "user-1005", + } + + # Each identity should have a trait called "key" + for identity in list(Identity.objects.filter(environment__project=project).all()): + trait_value = Trait.objects.get( + identity=identity, trait_key="key" + ).get_trait_value() + assert trait_value == identity.identifier + + +def test_process_import_request__rules_imported( + project: Project, + import_request: LaunchDarklyImportRequest, +): + # When + process_import_request(import_request) + + # Then + segments = Segment.objects.filter(project=project).exclude(feature_id=None) + + assert set(segments.values_list("name", flat=True)) == { + # Feature Segments + "Regular And", + "Reverted And", + "Just Not", + # Feature Segments without descriptions + "imported-56725db6-3d2a-4ed6-a2a1-60ef94ac62d5", + "imported-a132f4aa-ad51-43c6-8d03-f18d6a5b205d", + "imported-c034ec70-fcb3-4c15-9bea-b9fa0b341b4f", + # Individual targeting rules converted as custom segments + "individual-targeting-variation-0", + "individual-targeting-variation-1", + "individual-targeting-variation-2", + } + + # Tests for "Regular And" + + and_rule = SegmentRule.objects.get(segment__name="Regular And") + # Parents are always "ALL" rules. + assert and_rule.type == SegmentRule.ALL_RULE + + and_subrules = SegmentRule.objects.filter(rule=and_rule) + assert and_subrules.count() == 2 + # UI needs to have subrules as `ANY_RULE` to display properly. + assert list(and_subrules)[0].type == SegmentRule.ANY_RULE + assert list(and_subrules)[1].type == SegmentRule.ANY_RULE + + and_subconditions = Condition.objects.filter(rule__in=and_subrules) + assert and_subconditions.count() == 2 + assert set(and_subconditions.values_list("property", "operator", "value")) == { + ("p1", segment_constants.LESS_THAN_INCLUSIVE, "5"), + ("p2", segment_constants.GREATER_THAN, "1"), + } + + # Tests for "Reverted And" + + reverted_and_rule = SegmentRule.objects.get(segment__name="Reverted And") + # Parents are always "ALL" rules. + assert reverted_and_rule.type == SegmentRule.ALL_RULE + + reverted_and_subrules = SegmentRule.objects.filter(rule=reverted_and_rule).all() + assert reverted_and_subrules.count() == 2 + assert list(reverted_and_subrules)[0].type == SegmentRule.ANY_RULE + assert list(reverted_and_subrules)[1].type == SegmentRule.NONE_RULE + + reverted_and_any_subrule_conditions = Condition.objects.filter( + rule=reverted_and_subrules[0] + ) + assert reverted_and_any_subrule_conditions.count() == 1 + assert set( + reverted_and_any_subrule_conditions.values_list("property", "operator", "value") + ) == { + ("p1", segment_constants.REGEX, ".*bar"), + } + + reverted_and_none_subrule_conditions = Condition.objects.filter( + rule=reverted_and_subrules[1] + ) + assert reverted_and_none_subrule_conditions.count() == 2 + assert set( + reverted_and_none_subrule_conditions.values_list( + "property", "operator", "value" + ) + ) == { + ("p2", segment_constants.CONTAINS, "forbidden"), + ("p2", segment_constants.CONTAINS, "words"), + } + + # Tests for "Just Not + just_not_rule = SegmentRule.objects.get(segment__name="Just Not") + # Parents are always "ALL" rules. + assert just_not_rule.type == SegmentRule.ALL_RULE + + just_not_subrules = SegmentRule.objects.filter(rule=just_not_rule).all() + assert just_not_subrules.count() == 1 + assert list(just_not_subrules)[0].type == SegmentRule.NONE_RULE + + just_not_subrule_conditions = Condition.objects.filter(rule=just_not_subrules[0]) + assert just_not_subrule_conditions.count() == 1 + assert set( + just_not_subrule_conditions.values_list("property", "operator", "value") + ) == { + ("p1", segment_constants.IN, "this,that"), + } diff --git a/api/tests/unit/integrations/launch_darkly/test_views.py b/api/tests/unit/integrations/launch_darkly/test_views.py index 4b62f532163d..060dd1cde803 100644 --- a/api/tests/unit/integrations/launch_darkly/test_views.py +++ b/api/tests/unit/integrations/launch_darkly/test_views.py @@ -60,9 +60,9 @@ def test_launch_darkly_import_request_view__create__return_expected( "id": created_import_request.id, "project": project.id, "status": { - "error_message": None, + "error_messages": [], "requested_environment_count": 2, - "requested_flag_count": 5, + "requested_flag_count": 9, "result": None, }, "updated_at": mocker.ANY,