diff --git a/api/features/import_export/mappers.py b/api/features/import_export/mappers.py new file mode 100644 index 000000000000..0d2248b21f2a --- /dev/null +++ b/api/features/import_export/mappers.py @@ -0,0 +1,15 @@ +from features.import_export.types import FeatureExportData +from features.models import Feature +from projects.models import Project + + +def map_feature_export_data_to_feature( + feature_data: FeatureExportData, project: Project +) -> Feature: + return Feature( + name=feature_data["name"], + project=project, + initial_value=feature_data["initial_value"], + is_server_key_only=feature_data["is_server_key_only"], + default_enabled=feature_data["default_enabled"], + ) diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py new file mode 100644 index 000000000000..4e31b6f35ae6 --- /dev/null +++ b/api/features/import_export/services.py @@ -0,0 +1,79 @@ +from django.db.models import Q + +from environments.models import Environment +from features.import_export.types import FeatureExportData +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureOption + + +def overwrite_feature_for_environment( + feature_data: FeatureExportData, + existing_feature: Feature, + environment: Environment, +) -> None: + """ + Apply a destructive feature import to a single environment without + affecting other environments' feature states or the Feature definition. + """ + FeatureSegment.objects.filter( + feature=existing_feature, environment=environment + ).delete() + existing_feature.feature_states.filter( + environment=environment, identity__isnull=False + ).delete() + + feature_state = FeatureState.objects.get_live_feature_states( + environment=environment, + additional_filters=Q( + feature=existing_feature, + identity__isnull=True, + feature_segment__isnull=True, + ), + ).get() + + existing_options_by_value: dict[ + tuple[str | None, str | int | bool | None], MultivariateFeatureOption + ] = { + (option.type, option.value): option + for option in existing_feature.multivariate_options.all() + } + imported_option_ids: set[int] = set() + for mv_data in feature_data["multivariate"]: + key = (mv_data["type"], mv_data["value"]) + mv_option = existing_options_by_value.get(key) + if mv_option is None: + mv_option = MultivariateFeatureOption( + feature=existing_feature, + default_percentage_allocation=mv_data["default_percentage_allocation"], + type=mv_data["type"], + ) + setattr( + mv_option, + FeatureState.get_feature_state_key_name(mv_data["type"]), + mv_data["value"], + ) + mv_option.save() + imported_option_ids.add(mv_option.pk) + mv_state_value = feature_state.multivariate_feature_state_values.get( + multivariate_feature_option=mv_option, + ) + mv_state_value.percentage_allocation = mv_data["percentage_allocation"] + mv_state_value.save() + + for mv_state_value in feature_state.multivariate_feature_state_values.exclude( + multivariate_feature_option_id__in=imported_option_ids, + ): + if mv_state_value.percentage_allocation != 0: + mv_state_value.percentage_allocation = 0 + mv_state_value.save() + + feature_state_value = feature_state.feature_state_value + feature_state_value.type = feature_data["type"] + setattr( + feature_state_value, + FeatureState.get_feature_state_key_name(feature_data["type"]), + feature_data["value"], + ) + feature_state_value.save() + feature_state.enabled = feature_data["enabled"] + feature_state.save() diff --git a/api/features/import_export/tasks.py b/api/features/import_export/tasks.py index 040f89b7436e..cdfe7cfa6fef 100644 --- a/api/features/import_export/tasks.py +++ b/api/features/import_export/tasks.py @@ -1,6 +1,6 @@ import json from datetime import timedelta -from typing import Optional, Union +from typing import Optional from django.conf import settings from django.db.models import Q @@ -10,19 +10,22 @@ register_task_handler, ) -from environments.models import Environment -from features.models import Feature, FeatureStateValue -from features.multivariate.models import MultivariateFeatureOption -from features.value_types import BOOLEAN, INTEGER, STRING -from features.versioning.versioning_service import get_environment_flags_list -from projects.models import Project - -from .constants import FAILED, OVERWRITE_DESTRUCTIVE, PROCESSING, SKIP, SUCCESS -from .models import ( +from features.import_export.constants import ( + FAILED, + PROCESSING, + SKIP, + SUCCESS, +) +from features.import_export.mappers import map_feature_export_data_to_feature +from features.import_export.models import ( FeatureExport, FeatureImport, FlagsmithOnFlagsmithFeatureExport, ) +from features.import_export.services import overwrite_feature_for_environment +from features.import_export.types import FeatureExportData +from features.models import Feature +from features.versioning.versioning_service import get_environment_flags_list @register_recurring_task( @@ -141,7 +144,7 @@ def import_features_for_environment(feature_import_id: int) -> None: def _import_features_for_environment(feature_import: FeatureImport) -> None: environment = feature_import.environment - input_data = json.loads(feature_import.data) + input_data: list[FeatureExportData] = json.loads(feature_import.data) project = environment.project for feature_data in input_data: @@ -150,100 +153,19 @@ def _import_features_for_environment(feature_import: FeatureImport) -> None: project=project, ).first() - if existing_feature: - # Leave existing features completely alone. - if feature_import.strategy == SKIP: - continue + if existing_feature and feature_import.strategy == SKIP: + continue - # First destroy existing features that overlap. - if feature_import.strategy == OVERWRITE_DESTRUCTIVE: - existing_feature.delete() + if existing_feature is None: + existing_feature = map_feature_export_data_to_feature(feature_data, project) + existing_feature.save() - _create_new_feature(feature_data, project, environment) + overwrite_feature_for_environment(feature_data, existing_feature, environment) feature_import.status = SUCCESS feature_import.save() -def _save_feature_state_value_with_type( - value: Optional[Union[int, bool, str]], - type: str, - feature_state_value: FeatureStateValue, -) -> None: - feature_state_value.type = type - if feature_state_value.type == INTEGER: - feature_state_value.integer_value = value - elif feature_state_value.type == BOOLEAN: - feature_state_value.boolean_value = value # type: ignore[assignment] - else: - assert feature_state_value.type == STRING - feature_state_value.string_value = value - - feature_state_value.save() - - -def _create_multivariate_feature_option( - value: Optional[Union[int, bool, str]], - type: str, - feature: Feature, - default_percentage_allocation: Union[int, float], -) -> MultivariateFeatureOption: - mvfo = MultivariateFeatureOption( - feature=feature, - default_percentage_allocation=default_percentage_allocation, - type=type, - ) - if mvfo.type == INTEGER: - mvfo.integer_value = value - elif mvfo.type == BOOLEAN: - mvfo.boolean_value = value # type: ignore[assignment] - else: - assert mvfo.type == STRING - mvfo.string_value = value - - mvfo.save() - return mvfo - - -def _create_new_feature( - feature_data: dict[str, Optional[Union[bool, str, int]]], - project: Project, - environment: Environment, -) -> None: - feature = Feature.objects.create( - name=feature_data["name"], - project=project, - initial_value=feature_data["initial_value"], - is_server_key_only=feature_data["is_server_key_only"], - default_enabled=feature_data["default_enabled"], - ) - feature_state = feature.feature_states.get( - environment=environment, - ) - - for mv_data in feature_data["multivariate"]: # type: ignore[union-attr] - mv_feature_option = _create_multivariate_feature_option( - value=mv_data["value"], # type: ignore[index] - type=mv_data["type"], # type: ignore[index] - feature=feature, - default_percentage_allocation=mv_data["default_percentage_allocation"], # type: ignore[arg-type,index] - ) - mv_feature_state_value = feature_state.multivariate_feature_state_values.filter( - multivariate_feature_option=mv_feature_option - ).first() - mv_feature_state_value.percentage_allocation = mv_data["percentage_allocation"] # type: ignore[index] - mv_feature_state_value.save() - - feature_state_value = feature_state.feature_state_value - _save_feature_state_value_with_type( - value=feature_data["value"], - type=feature_data["type"], # type: ignore[arg-type] - feature_state_value=feature_state_value, - ) - feature_state.enabled = feature_data["enabled"] - feature_state.save() - - # Should only run on official flagsmith instance. if ( settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID diff --git a/api/features/import_export/types.py b/api/features/import_export/types.py new file mode 100644 index 000000000000..fe440621b7df --- /dev/null +++ b/api/features/import_export/types.py @@ -0,0 +1,19 @@ +from typing import TypedDict + + +class FeatureExportMultivariateData(TypedDict): + percentage_allocation: float + default_percentage_allocation: float + value: str | int | bool | None + type: str + + +class FeatureExportData(TypedDict): + name: str + default_enabled: bool + is_server_key_only: bool + initial_value: str | None + value: str | int | bool | None + type: str + enabled: bool + multivariate: list[FeatureExportMultivariateData] diff --git a/api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py b/api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py index 40530ab3e59b..702f9a20b841 100644 --- a/api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py +++ b/api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest +from django.db.models import Q from django.utils import timezone from freezegun.api import FrozenDateTimeFactory from pytest_django.fixtures import SettingsWrapper @@ -31,9 +32,11 @@ from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureOption from features.value_types import STRING +from features.versioning.models import EnvironmentFeatureVersion from organisations.models import Organisation from projects.models import Project from projects.tags.models import Tag +from segments.models import Segment def test_clear_stale_feature_imports_and_exports__stale_records__deletes_old_keeps_new( # type: ignore[no-untyped-def] @@ -234,6 +237,19 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov initial_value="changeme", ) + feature1_mv_option = MultivariateFeatureOption.objects.create( + feature=feature1, + default_percentage_allocation=40, + type=STRING, + string_value="feature1_mv", + ) + feature_state1 = feature1.feature_states.get(environment=environment) + feature1_mv_state_value = feature_state1.multivariate_feature_state_values.get( + multivariate_feature_option=feature1_mv_option + ) + feature1_mv_state_value.percentage_allocation = 65 + feature1_mv_state_value.save() + multivariate_feature_option1 = MultivariateFeatureOption.objects.create( feature=feature2, default_percentage_allocation=30, @@ -286,13 +302,91 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov organisation2 = Organisation.objects.create(name="Receiving") project2 = Project.objects.create(name="Web", organisation=organisation2) environment2 = Environment.objects.create(name="Bat", project=project2) - Environment.objects.create(name="Ignore Me", project=project2) + bystander_environment = Environment.objects.create( + name="Ignore Me", project=project2 + ) overlapping_feature = Feature.objects.create( name="3", project=project2, initial_value="keepme", ) + + # Overlapping multivariate feature with a matching MV option (reuse path) + # and an extra option not in the import (zero-out path). + overlapping_feature_2 = Feature.objects.create( + name="2", + project=project2, + initial_value="overwrite_me", + ) + overlapping_mv_option_match = MultivariateFeatureOption.objects.create( + feature=overlapping_feature_2, + default_percentage_allocation=30, + type=STRING, + string_value="mv_feature_option1", + ) + overlapping_mv_option_extra = MultivariateFeatureOption.objects.create( + feature=overlapping_feature_2, + default_percentage_allocation=25, + type=STRING, + string_value="extra_option", + ) + overlapping_feature_2_state = overlapping_feature_2.feature_states.get( + environment=environment2 + ) + overlapping_mv_match_value = ( + overlapping_feature_2_state.multivariate_feature_state_values.get( + multivariate_feature_option=overlapping_mv_option_match + ) + ) + overlapping_mv_match_value.percentage_allocation = 60 + overlapping_mv_match_value.save() + overlapping_mv_extra_value = ( + overlapping_feature_2_state.multivariate_feature_state_values.get( + multivariate_feature_option=overlapping_mv_option_extra + ) + ) + overlapping_mv_extra_value.percentage_allocation = 40 + overlapping_mv_extra_value.save() + overlapping_feature_2_pk = overlapping_feature_2.pk + overlapping_mv_option_match_pk = overlapping_mv_option_match.pk + overlapping_mv_option_extra_pk = overlapping_mv_option_extra.pk + + # Bystander env state on the overlapping feature should survive the import. + bystander_fs = overlapping_feature.feature_states.get( + environment=bystander_environment + ) + bystander_fs.enabled = True + bystander_fs.save() + bystander_fs.feature_state_value.type = STRING + bystander_fs.feature_state_value.string_value = "bystander_value" + bystander_fs.feature_state_value.save() + bystander_fs_pk = bystander_fs.pk + + # Target env override state should be destroyed by the import. + target_segment = Segment.objects.create(name="Beta", project=project2) + target_feature_segment = FeatureSegment.objects.create( + feature=overlapping_feature, + segment=target_segment, + environment=environment2, + ) + target_segment_fs = FeatureState.objects.create( + feature=overlapping_feature, + environment=environment2, + feature_segment=target_feature_segment, + ) + target_identity = Identity.objects.create( + identifier="target-id", environment=environment2 + ) + target_identity_fs = FeatureState.objects.create( + feature=overlapping_feature, + environment=environment2, + identity=target_identity, + ) + target_segment_pk = target_feature_segment.pk + target_segment_fs_pk = target_segment_fs.pk + target_identity_fs_pk = target_identity_fs.pk + feature_export = FeatureExport.objects.create( environment=environment, status=PROCESSING, @@ -314,19 +408,37 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov # Then assert project2.features.count() == 3 overlapping_feature.refresh_from_db() - assert overlapping_feature.deleted_at < timezone.now() + assert overlapping_feature.deleted_at is None new_feature1 = project2.features.get(name="1") new_feature2 = project2.features.get(name="2") new_feature3 = project2.features.get(name="3") + assert new_feature3.pk == overlapping_feature.pk - assert new_feature1.type == STANDARD + assert new_feature1.type == MULTIVARIATE assert new_feature1.initial_value == "200" assert new_feature1.is_server_key_only is True assert new_feature1.default_enabled is True + # Newly created MV feature has its options + allocations populated by the + # create-strategy path. + assert new_feature1.multivariate_options.count() == 1 + new_feature1_mv_option = new_feature1.multivariate_options.get( + string_value="feature1_mv" + ) + assert new_feature1_mv_option.default_percentage_allocation == 40 + new_feature1_state = new_feature1.feature_states.get(environment=environment2) + new_feature1_mv_value = new_feature1_state.multivariate_feature_state_values.get( + multivariate_feature_option=new_feature1_mv_option + ) + assert new_feature1_mv_value.percentage_allocation == 65 + + # Overlapping MV feature was updated in place: pk preserved, Feature + # definition (initial_value, is_server_key_only, default_enabled) preserved + # — destructive overwrite touches only env-default state, not the Feature. + assert new_feature2.pk == overlapping_feature_2_pk assert new_feature2.type == MULTIVARIATE - assert new_feature2.initial_value == "banana" + assert new_feature2.initial_value == "overwrite_me" assert new_feature2.is_server_key_only is False assert new_feature2.default_enabled is False @@ -337,13 +449,21 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov new_feature_state2 = queryset.first() - assert new_feature2.multivariate_options.count() == 2 + # Three options: the matching one was reused (pk preserved), the second is + # newly created from the import, the extra one survives but its target-env + # allocation is zeroed. + assert new_feature2.multivariate_options.count() == 3 new_mv_feature_option1 = new_feature2.multivariate_options.get( string_value="mv_feature_option1" ) new_mv_feature_option2 = new_feature2.multivariate_options.get( string_value="mv_feature_option2" ) + new_mv_feature_option_extra = new_feature2.multivariate_options.get( + string_value="extra_option" + ) + assert new_mv_feature_option1.pk == overlapping_mv_option_match_pk + assert new_mv_feature_option_extra.pk == overlapping_mv_option_extra_pk new_mv_fs_value1 = new_feature_state2.multivariate_feature_state_values.get( multivariate_feature_option=new_mv_feature_option1 @@ -351,8 +471,12 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov new_mv_fs_value2 = new_feature_state2.multivariate_feature_state_values.get( multivariate_feature_option=new_mv_feature_option2 ) + new_mv_fs_value_extra = new_feature_state2.multivariate_feature_state_values.get( + multivariate_feature_option=new_mv_feature_option_extra + ) assert new_mv_fs_value1.percentage_allocation == 90 assert new_mv_fs_value2.percentage_allocation == 10 + assert new_mv_fs_value_extra.percentage_allocation == 0 assert new_mv_feature_option1.type == STRING assert new_mv_feature_option1.default_percentage_allocation == 30 @@ -360,7 +484,7 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov assert new_mv_feature_option2.default_percentage_allocation == 70 assert new_feature3.type == STANDARD - assert new_feature3.initial_value == "changeme" + assert new_feature3.initial_value == "keepme" queryset = new_feature3.feature_states.filter( environment=environment2, @@ -372,6 +496,112 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov assert new_feature_state3.feature_state_value.type == STRING assert new_feature_state3.feature_state_value.value == "changed" + # Bystander env's FeatureState row is preserved unchanged. + bystander_fs.refresh_from_db() + assert bystander_fs.pk == bystander_fs_pk + assert bystander_fs.enabled is True + assert bystander_fs.feature_state_value.value == "bystander_value" + + # Target env's segment + identity overrides are gone (live filter excludes them). + assert not FeatureSegment.objects.filter(pk=target_segment_pk).exists() + assert not FeatureState.objects.filter( + pk=target_segment_fs_pk, deleted_at__isnull=True + ).exists() + assert not FeatureState.objects.filter( + pk=target_identity_fs_pk, deleted_at__isnull=True + ).exists() + + +def test_export_and_import_features__overwrite_destructive_with_v2_versioning__updates_live_version_in_place( + db: None, + environment: Environment, + project: Project, +) -> None: + # Given a source env exporting a feature with a known value, and a target + # v2-versioned env where the overlapping feature has multiple published + # versions (so the initial version is no longer the live one). + source_feature = Feature.objects.create( + name="3", project=project, initial_value="changeme" + ) + source_fs = source_feature.feature_states.get(environment=environment) + source_fs.enabled = True + source_fs.save() + source_fs.feature_state_value.type = STRING + source_fs.feature_state_value.string_value = "imported_value" + source_fs.feature_state_value.save() + + organisation2 = Organisation.objects.create(name="Receiving") + project2 = Project.objects.create(name="Web", organisation=organisation2) + target_env = Environment.objects.create( + name="Target", project=project2, use_v2_feature_versioning=True + ) + target_feature = Feature.objects.create( + name="3", project=project2, initial_value="keepme" + ) + + initial_version = EnvironmentFeatureVersion.objects.get( + environment=target_env, feature=target_feature + ) + initial_fs = initial_version.feature_states.get( + identity__isnull=True, feature_segment__isnull=True + ) + initial_fs.feature_state_value.type = STRING + initial_fs.feature_state_value.string_value = "initial_value" + initial_fs.feature_state_value.save() + + second_version = EnvironmentFeatureVersion.objects.create( + environment=target_env, feature=target_feature + ) + second_version.publish() + + initial_fs_pk = initial_fs.pk + initial_version_pk = initial_version.pk + second_version_pk = second_version.pk + + feature_export = FeatureExport.objects.create( + environment=environment, status=PROCESSING + ) + + # When + export_features_for_environment(feature_export.id) + feature_export.refresh_from_db() + + feature_import = FeatureImport.objects.create( # type: ignore[misc] + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=feature_export.data, + ) + import_features_for_environment(feature_import.id) + + # Then no new version is created (destructive means destructive — no audit + # trail of an extra published version), the live version's FS reflects the + # imported value, and the prior version's FS is left untouched. + versions = EnvironmentFeatureVersion.objects.filter( + environment=target_env, feature=target_feature + ) + assert versions.count() == 2 + assert {v.pk for v in versions} == {initial_version_pk, second_version_pk} + + initial_fs.refresh_from_db() + assert initial_fs.pk == initial_fs_pk + assert initial_fs.feature_state_value.value == "initial_value" + + live_states = list( + FeatureState.objects.get_live_feature_states( + environment=target_env, + additional_filters=Q( + feature=target_feature, + identity__isnull=True, + feature_segment__isnull=True, + ), + ) + ) + assert len(live_states) == 1 + live_fs = live_states[0] + assert live_fs.environment_feature_version_id == second_version.uuid + assert live_fs.enabled is True + assert live_fs.feature_state_value.value == "imported_value" + def test_create_flagsmith_on_flagsmith_feature_export__valid_config__creates_export( db: None, diff --git a/docs/docs/administration-and-security/data-management/bulk-import-and-export.md b/docs/docs/administration-and-security/data-management/bulk-import-and-export.md index 6e459e73dff8..b48b6269f33b 100644 --- a/docs/docs/administration-and-security/data-management/bulk-import-and-export.md +++ b/docs/docs/administration-and-security/data-management/bulk-import-and-export.md @@ -38,7 +38,7 @@ Feature exports are available for only two weeks, so if an export is needed for On the Import tab of the project settings page, you will find the feature import functionality, complete with file upload at the bottom of the page. :::caution -The target environment is the environment to inherit the features of the exported environment. All other environments will be set to the values defined when the feature was initially created. Use with caution, especially when using the Overwrite Destructive merge strategy. +The target environment is the environment to inherit the features of the exported environment. The Overwrite Destructive merge strategy will replace the target environment's state for every overlapping feature — including its segment and identity overrides — so use it with caution. ::: ### Merge Strategy @@ -47,4 +47,4 @@ Since a feature may have an identical name, it is important to carefully select The first option is the Skip strategy, which allows an import to process a feature export and, at any time a feature has a pre-existing feature present, it skips the import for that given feature. For example, if you are importing ten features but two of them were already there, only eight features will be added to the project. This strategy is best for organisations that want to retain their existing data as closely as possible. -The second option is the Overwrite Destructive strategy. In contrast to the Skip strategy, the Overwrite Destructive method overwrites your existing features, and it is important to remember that this is across all environments. This strategy is most useful only when every feature that was included in the export was vetted to be authoritative in the target project. \ No newline at end of file +The second option is the Overwrite Destructive strategy. In contrast to the Skip strategy, Overwrite Destructive replaces the target environment's state for every overlapping feature — env-default value, enabled flag, segment and identity overrides, and multivariate allocations. The feature definition (name, initial value, default enabled, server-key-only) and other environments' state are preserved. This strategy is most useful when every feature that was included in the export was vetted to be authoritative for the target environment. \ No newline at end of file diff --git a/frontend/web/components/import-export/FeatureImport.tsx b/frontend/web/components/import-export/FeatureImport.tsx index 265514ddc351..933e28d60bdf 100644 --- a/frontend/web/components/import-export/FeatureImport.tsx +++ b/frontend/web/components/import-export/FeatureImport.tsx @@ -320,7 +320,7 @@ const FeatureExport: FC = ({ projectId }) => { {strategy === 'OVERWRITE_DESTRUCTIVE' && ( )}