From d0e045d1a57c9e4e8792fc861998e48765862e32 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 27 Apr 2026 15:18:40 +0100 Subject: [PATCH 1/9] fix(Import/Export): Destructive environment-level import resets feature states in other environments beep boop --- api/features/import_export/tasks.py | 87 ++++- .../test_unit_features_import_export_tasks.py | 319 +++++++++++++++++- 2 files changed, 400 insertions(+), 6 deletions(-) diff --git a/api/features/import_export/tasks.py b/api/features/import_export/tasks.py index 040f89b7436e..2ea72dbc47d6 100644 --- a/api/features/import_export/tasks.py +++ b/api/features/import_export/tasks.py @@ -11,8 +11,11 @@ ) from environments.models import Environment -from features.models import Feature, FeatureStateValue -from features.multivariate.models import MultivariateFeatureOption +from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) from features.value_types import BOOLEAN, INTEGER, STRING from features.versioning.versioning_service import get_environment_flags_list from projects.models import Project @@ -151,13 +154,14 @@ def _import_features_for_environment(feature_import: FeatureImport) -> None: ).first() if existing_feature: - # Leave existing features completely alone. if feature_import.strategy == SKIP: continue - # First destroy existing features that overlap. if feature_import.strategy == OVERWRITE_DESTRUCTIVE: - existing_feature.delete() + _overwrite_feature_for_environment( + feature_data, existing_feature, environment + ) + continue _create_new_feature(feature_data, project, environment) @@ -205,6 +209,79 @@ def _create_multivariate_feature_option( return mvfo +def _overwrite_feature_for_environment( + feature_data: dict[str, Optional[Union[bool, str, int]]], + existing_feature: Feature, + environment: Environment, +) -> None: + existing_feature.initial_value = feature_data["initial_value"] + existing_feature.is_server_key_only = feature_data["is_server_key_only"] # type: ignore[assignment] + existing_feature.default_enabled = feature_data["default_enabled"] # type: ignore[assignment] + existing_feature.save() + + FeatureSegment.objects.filter( + feature=existing_feature, environment=environment + ).delete() + existing_feature.feature_states.filter( + environment=environment, identity__isnull=False + ).delete() + + feature_state = existing_feature.feature_states.filter( + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ).first() + if feature_state is None: + feature_state = FeatureState.objects.create( + feature=existing_feature, + environment=environment, + ) + + existing_options_by_value = { + (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"]: # type: ignore[union-attr] + key = (mv_data["type"], mv_data["value"]) # type: ignore[index] + mv_option = existing_options_by_value.get(key) + if mv_option is None: + mv_option = _create_multivariate_feature_option( + value=mv_data["value"], # type: ignore[index] + type=mv_data["type"], # type: ignore[index] + feature=existing_feature, + default_percentage_allocation=mv_data["default_percentage_allocation"], # type: ignore[arg-type,index] + ) + imported_option_ids.add(mv_option.pk) + mv_state_value = feature_state.multivariate_feature_state_values.filter( + multivariate_feature_option=mv_option, + ).first() + if mv_state_value is None: + MultivariateFeatureStateValue.objects.create( + feature_state=feature_state, + multivariate_feature_option=mv_option, + percentage_allocation=mv_data["percentage_allocation"], # type: ignore[index] + ) + else: + mv_state_value.percentage_allocation = mv_data["percentage_allocation"] # type: ignore[index] + 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() + + _save_feature_state_value_with_type( + value=feature_data["value"], + type=feature_data["type"], # type: ignore[arg-type] + feature_state_value=feature_state.feature_state_value, + ) + feature_state.enabled = feature_data["enabled"] # type: ignore[assignment] + feature_state.save() + + def _create_new_feature( feature_data: dict[str, Optional[Union[bool, str, int]]], project: Project, 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..f1b4cd1d4c09 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 @@ -34,6 +34,7 @@ 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] @@ -314,11 +315,12 @@ 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.initial_value == "200" @@ -373,6 +375,321 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov assert new_feature_state3.feature_state_value.value == "changed" +def test_import_features_for_environment__overwrite_destructive__leaves_other_environments_unchanged( + db: None, +) -> None: + # Given + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + target_env = Environment.objects.create(name="Target", project=project) + bystander_env = Environment.objects.create(name="Bystander", project=project) + + existing_feature = Feature.objects.create( + name="3", + project=project, + initial_value="keepme", + default_enabled=False, + ) + mv_option_1 = MultivariateFeatureOption.objects.create( + feature=existing_feature, + default_percentage_allocation=30, + type=STRING, + string_value="mv1", + ) + mv_option_2 = MultivariateFeatureOption.objects.create( + feature=existing_feature, + default_percentage_allocation=70, + type=STRING, + string_value="mv2", + ) + + bystander_fs = existing_feature.feature_states.get(environment=bystander_env) + 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_mv_value_1 = bystander_fs.multivariate_feature_state_values.get( + multivariate_feature_option=mv_option_1 + ) + bystander_mv_value_1.percentage_allocation = 25 + bystander_mv_value_1.save() + bystander_mv_value_2 = bystander_fs.multivariate_feature_state_values.get( + multivariate_feature_option=mv_option_2 + ) + bystander_mv_value_2.percentage_allocation = 75 + bystander_mv_value_2.save() + + feature_pk = existing_feature.pk + bystander_fs_pk = bystander_fs.pk + bystander_fsv_pk = bystander_fs.feature_state_value.pk + bystander_mv_value_1_pk = bystander_mv_value_1.pk + bystander_mv_value_2_pk = bystander_mv_value_2.pk + + payload = [ + { + "name": "3", + "default_enabled": True, + "is_server_key_only": False, + "initial_value": "imported", + "value": "imported_target_value", + "type": STRING, + "enabled": True, + "multivariate": [ + { + "percentage_allocation": 90, + "default_percentage_allocation": 30, + "value": "mv1", + "type": STRING, + }, + { + "percentage_allocation": 10, + "default_percentage_allocation": 70, + "value": "mv2", + "type": STRING, + }, + ], + } + ] + feature_import = FeatureImport.objects.create( # type: ignore[misc] + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then + existing_feature.refresh_from_db() + assert existing_feature.pk == feature_pk + + bystander_fs.refresh_from_db() + assert bystander_fs.pk == bystander_fs_pk + assert bystander_fs.enabled is True + assert bystander_fs.feature_state_value.pk == bystander_fsv_pk + assert bystander_fs.feature_state_value.value == "bystander_value" + + bystander_mv_value_1.refresh_from_db() + bystander_mv_value_2.refresh_from_db() + assert bystander_mv_value_1.pk == bystander_mv_value_1_pk + assert bystander_mv_value_1.percentage_allocation == 25 + assert bystander_mv_value_2.pk == bystander_mv_value_2_pk + assert bystander_mv_value_2.percentage_allocation == 75 + + +def test_import_features_for_environment__overwrite_destructive__deletes_target_environment_segment_and_identity_overrides( + db: None, +) -> None: + # Given + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + target_env = Environment.objects.create(name="Target", project=project) + bystander_env = Environment.objects.create(name="Bystander", project=project) + segment = Segment.objects.create(name="Beta", project=project) + + existing_feature = Feature.objects.create( + name="3", + project=project, + initial_value="keepme", + ) + + target_segment = FeatureSegment.objects.create( + feature=existing_feature, segment=segment, environment=target_env + ) + target_segment_fs = FeatureState.objects.create( + feature=existing_feature, + environment=target_env, + feature_segment=target_segment, + ) + bystander_segment = FeatureSegment.objects.create( + feature=existing_feature, segment=segment, environment=bystander_env + ) + bystander_segment_fs = FeatureState.objects.create( + feature=existing_feature, + environment=bystander_env, + feature_segment=bystander_segment, + ) + + target_identity = Identity.objects.create( + identifier="target-id", environment=target_env + ) + target_identity_fs = FeatureState.objects.create( + feature=existing_feature, environment=target_env, identity=target_identity + ) + bystander_identity = Identity.objects.create( + identifier="bystander-id", environment=bystander_env + ) + bystander_identity_fs = FeatureState.objects.create( + feature=existing_feature, + environment=bystander_env, + identity=bystander_identity, + ) + + target_segment_pk = target_segment.pk + target_segment_fs_pk = target_segment_fs.pk + target_identity_fs_pk = target_identity_fs.pk + bystander_segment_pk = bystander_segment.pk + bystander_segment_fs_pk = bystander_segment_fs.pk + bystander_identity_fs_pk = bystander_identity_fs.pk + + payload = [ + { + "name": "3", + "default_enabled": False, + "is_server_key_only": False, + "initial_value": "imported", + "value": "imported", + "type": STRING, + "enabled": False, + "multivariate": [], + } + ] + feature_import = FeatureImport.objects.create( # type: ignore[misc] + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then + 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() + + assert FeatureSegment.objects.filter(pk=bystander_segment_pk).exists() + assert FeatureState.objects.filter( + pk=bystander_segment_fs_pk, deleted_at__isnull=True + ).exists() + assert FeatureState.objects.filter( + pk=bystander_identity_fs_pk, deleted_at__isnull=True + ).exists() + + +def test_import_features_for_environment__overwrite_destructive_with_missing_target_feature_state__creates_environment_default( + db: None, +) -> None: + # Given + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + bystander_env = Environment.objects.create(name="Bystander", project=project) + existing_feature = Feature.objects.create( + name="3", + project=project, + initial_value="keepme", + ) + target_env = Environment.objects.create(name="Target", project=project) + existing_feature.feature_states.filter(environment=target_env).delete() + + feature_pk = existing_feature.pk + bystander_fs_pk = existing_feature.feature_states.get(environment=bystander_env).pk + + payload = [ + { + "name": "3", + "default_enabled": True, + "is_server_key_only": False, + "initial_value": "imported", + "value": "imported_target", + "type": STRING, + "enabled": True, + "multivariate": [], + } + ] + feature_import = FeatureImport.objects.create( # type: ignore[misc] + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then + existing_feature.refresh_from_db() + assert existing_feature.pk == feature_pk + + target_fs_qs = existing_feature.feature_states.filter( + environment=target_env, + identity__isnull=True, + feature_segment__isnull=True, + ) + assert target_fs_qs.count() == 1 + target_fs = target_fs_qs.get() + assert target_fs.enabled is True + assert target_fs.feature_state_value.value == "imported_target" + + assert ( + existing_feature.feature_states.get(environment=bystander_env).pk + == bystander_fs_pk + ) + + +def test_import_features_for_environment__overwrite_destructive_with_matching_feature__updates_target_environment_value_and_enabled( + db: None, +) -> None: + # Given + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + target_env = Environment.objects.create(name="Target", project=project) + existing_feature = Feature.objects.create( + name="3", + project=project, + initial_value="keepme", + default_enabled=False, + is_server_key_only=False, + ) + target_fs = existing_feature.feature_states.get(environment=target_env) + target_fs.enabled = False + target_fs.save() + target_fs.feature_state_value.type = STRING + target_fs.feature_state_value.string_value = "old_value" + target_fs.feature_state_value.save() + + feature_pk = existing_feature.pk + target_fs_pk = target_fs.pk + + payload = [ + { + "name": "3", + "default_enabled": True, + "is_server_key_only": True, + "initial_value": "imported_initial", + "value": "imported_value", + "type": STRING, + "enabled": True, + "multivariate": [], + } + ] + feature_import = FeatureImport.objects.create( # type: ignore[misc] + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then + existing_feature.refresh_from_db() + assert existing_feature.pk == feature_pk + assert existing_feature.initial_value == "imported_initial" + assert existing_feature.default_enabled is True + assert existing_feature.is_server_key_only is True + + target_fs.refresh_from_db() + assert target_fs.pk == target_fs_pk + assert target_fs.enabled is True + assert target_fs.feature_state_value.value == "imported_value" + + def test_create_flagsmith_on_flagsmith_feature_export__valid_config__creates_export( db: None, settings: SettingsWrapper, From f3938ecfdcff01b4c94938882056e7fca865b247 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 27 Apr 2026 15:27:23 +0100 Subject: [PATCH 2/9] fix(Import/Export): Remove unused type-ignore comments beep boop --- api/features/import_export/tasks.py | 2 +- .../test_unit_features_import_export_tasks.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/features/import_export/tasks.py b/api/features/import_export/tasks.py index 2ea72dbc47d6..4da41bcc5244 100644 --- a/api/features/import_export/tasks.py +++ b/api/features/import_export/tasks.py @@ -278,7 +278,7 @@ def _overwrite_feature_for_environment( type=feature_data["type"], # type: ignore[arg-type] feature_state_value=feature_state.feature_state_value, ) - feature_state.enabled = feature_data["enabled"] # type: ignore[assignment] + feature_state.enabled = feature_data["enabled"] feature_state.save() 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 f1b4cd1d4c09..0b8e143ecf07 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 @@ -452,7 +452,7 @@ def test_import_features_for_environment__overwrite_destructive__leaves_other_en ], } ] - feature_import = FeatureImport.objects.create( # type: ignore[misc] + feature_import = FeatureImport.objects.create( environment=target_env, strategy=OVERWRITE_DESTRUCTIVE, data=json.dumps(payload), @@ -546,7 +546,7 @@ def test_import_features_for_environment__overwrite_destructive__deletes_target_ "multivariate": [], } ] - feature_import = FeatureImport.objects.create( # type: ignore[misc] + feature_import = FeatureImport.objects.create( environment=target_env, strategy=OVERWRITE_DESTRUCTIVE, data=json.dumps(payload), @@ -603,7 +603,7 @@ def test_import_features_for_environment__overwrite_destructive_with_missing_tar "multivariate": [], } ] - feature_import = FeatureImport.objects.create( # type: ignore[misc] + feature_import = FeatureImport.objects.create( environment=target_env, strategy=OVERWRITE_DESTRUCTIVE, data=json.dumps(payload), @@ -668,7 +668,7 @@ def test_import_features_for_environment__overwrite_destructive_with_matching_fe "multivariate": [], } ] - feature_import = FeatureImport.objects.create( # type: ignore[misc] + feature_import = FeatureImport.objects.create( environment=target_env, strategy=OVERWRITE_DESTRUCTIVE, data=json.dumps(payload), From ae0681939f484455d7b64846ed82081a0256d994 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 27 Apr 2026 16:03:44 +0100 Subject: [PATCH 3/9] refactor(Import/Export): Extract import strategy logic into services and typed payload beep boop --- api/features/import_export/services.py | 139 +++++++++++++++++++ api/features/import_export/tasks.py | 185 +++---------------------- api/features/import_export/types.py | 19 +++ 3 files changed, 176 insertions(+), 167 deletions(-) create mode 100644 api/features/import_export/services.py create mode 100644 api/features/import_export/types.py diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py new file mode 100644 index 000000000000..3e232ce30864 --- /dev/null +++ b/api/features/import_export/services.py @@ -0,0 +1,139 @@ +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, + MultivariateFeatureStateValue, +) +from projects.models import Project + + +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. + """ + existing_feature.initial_value = feature_data["initial_value"] + existing_feature.is_server_key_only = feature_data["is_server_key_only"] + existing_feature.default_enabled = feature_data["default_enabled"] + existing_feature.save() + + FeatureSegment.objects.filter( + feature=existing_feature, environment=environment + ).delete() + existing_feature.feature_states.filter( + environment=environment, identity__isnull=False + ).delete() + + feature_state = existing_feature.feature_states.filter( + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ).first() + if feature_state is None: + feature_state = FeatureState.objects.create( + feature=existing_feature, + environment=environment, + ) + + 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.filter( + multivariate_feature_option=mv_option, + ).first() + if mv_state_value is None: + MultivariateFeatureStateValue.objects.create( + feature_state=feature_state, + multivariate_feature_option=mv_option, + percentage_allocation=mv_data["percentage_allocation"], + ) + else: + 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() + + +def create_feature_for_environment( + feature_data: FeatureExportData, + 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"]: + mv_feature_option = MultivariateFeatureOption( + feature=feature, + default_percentage_allocation=mv_data["default_percentage_allocation"], + type=mv_data["type"], + ) + setattr( + mv_feature_option, + FeatureState.get_feature_state_key_name(mv_data["type"]), + mv_data["value"], + ) + mv_feature_option.save() + 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"] + mv_feature_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 4da41bcc5244..1798c77e5589 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,22 +10,25 @@ register_task_handler, ) -from environments.models import Environment -from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue -from features.multivariate.models import ( - MultivariateFeatureOption, - MultivariateFeatureStateValue, +from features.import_export.constants import ( + FAILED, + OVERWRITE_DESTRUCTIVE, + PROCESSING, + SKIP, + SUCCESS, ) -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.models import ( FeatureExport, FeatureImport, FlagsmithOnFlagsmithFeatureExport, ) +from features.import_export.services import ( + create_feature_for_environment, + 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( @@ -144,7 +147,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: @@ -158,169 +161,17 @@ def _import_features_for_environment(feature_import: FeatureImport) -> None: continue if feature_import.strategy == OVERWRITE_DESTRUCTIVE: - _overwrite_feature_for_environment( + overwrite_feature_for_environment( feature_data, existing_feature, environment ) continue - _create_new_feature(feature_data, project, environment) + create_feature_for_environment(feature_data, project, 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 _overwrite_feature_for_environment( - feature_data: dict[str, Optional[Union[bool, str, int]]], - existing_feature: Feature, - environment: Environment, -) -> None: - existing_feature.initial_value = feature_data["initial_value"] - existing_feature.is_server_key_only = feature_data["is_server_key_only"] # type: ignore[assignment] - existing_feature.default_enabled = feature_data["default_enabled"] # type: ignore[assignment] - existing_feature.save() - - FeatureSegment.objects.filter( - feature=existing_feature, environment=environment - ).delete() - existing_feature.feature_states.filter( - environment=environment, identity__isnull=False - ).delete() - - feature_state = existing_feature.feature_states.filter( - environment=environment, - identity__isnull=True, - feature_segment__isnull=True, - ).first() - if feature_state is None: - feature_state = FeatureState.objects.create( - feature=existing_feature, - environment=environment, - ) - - existing_options_by_value = { - (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"]: # type: ignore[union-attr] - key = (mv_data["type"], mv_data["value"]) # type: ignore[index] - mv_option = existing_options_by_value.get(key) - if mv_option is None: - mv_option = _create_multivariate_feature_option( - value=mv_data["value"], # type: ignore[index] - type=mv_data["type"], # type: ignore[index] - feature=existing_feature, - default_percentage_allocation=mv_data["default_percentage_allocation"], # type: ignore[arg-type,index] - ) - imported_option_ids.add(mv_option.pk) - mv_state_value = feature_state.multivariate_feature_state_values.filter( - multivariate_feature_option=mv_option, - ).first() - if mv_state_value is None: - MultivariateFeatureStateValue.objects.create( - feature_state=feature_state, - multivariate_feature_option=mv_option, - percentage_allocation=mv_data["percentage_allocation"], # type: ignore[index] - ) - else: - mv_state_value.percentage_allocation = mv_data["percentage_allocation"] # type: ignore[index] - 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() - - _save_feature_state_value_with_type( - value=feature_data["value"], - type=feature_data["type"], # type: ignore[arg-type] - feature_state_value=feature_state.feature_state_value, - ) - feature_state.enabled = feature_data["enabled"] - feature_state.save() - - -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] From 3ed0ca7c979d85c8f997080698d6e28c4b994e93 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 27 Apr 2026 16:51:50 +0100 Subject: [PATCH 4/9] fix(Import/Export): Destructive import is invisible to live state in v2-versioned environments beep boop --- api/features/import_export/services.py | 27 ++-- .../test_unit_features_import_export_tasks.py | 117 ++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py index 3e232ce30864..18e4fde54c20 100644 --- a/api/features/import_export/services.py +++ b/api/features/import_export/services.py @@ -1,3 +1,5 @@ +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 @@ -5,6 +7,7 @@ MultivariateFeatureOption, MultivariateFeatureStateValue, ) +from features.versioning.models import EnvironmentFeatureVersion from projects.models import Project @@ -29,16 +32,26 @@ def overwrite_feature_for_environment( environment=environment, identity__isnull=False ).delete() - feature_state = existing_feature.feature_states.filter( + feature_state = FeatureState.objects.get_live_feature_states( environment=environment, - identity__isnull=True, - feature_segment__isnull=True, + additional_filters=Q( + feature=existing_feature, + identity__isnull=True, + feature_segment__isnull=True, + ), ).first() if feature_state is None: - feature_state = FeatureState.objects.create( - feature=existing_feature, - environment=environment, - ) + fs_kwargs: dict[str, object] = { + "feature": existing_feature, + "environment": environment, + } + if environment.use_v2_feature_versioning: + fs_kwargs["environment_feature_version"] = ( + EnvironmentFeatureVersion.create_initial_version( + environment=environment, feature=existing_feature + ) + ) + feature_state = FeatureState.objects.create(**fs_kwargs) existing_options_by_value: dict[ tuple[str | None, str | int | bool | None], MultivariateFeatureOption 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 0b8e143ecf07..03125ce62501 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,6 +32,7 @@ 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 @@ -690,6 +692,121 @@ def test_import_features_for_environment__overwrite_destructive_with_matching_fe assert target_fs.feature_state_value.value == "imported_value" +def test_import_features_for_environment__overwrite_destructive_with_v2_versioning_and_missing_target_feature_state__creates_versioned_live_feature_state( + db: None, +) -> None: + # Given a v2-versioned environment where the feature has no FeatureState + # (simulating the legacy "feature predates env" case). + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + Environment.objects.create(name="Bystander", project=project) + existing_feature = Feature.objects.create( + name="3", project=project, initial_value="keepme" + ) + target_env = Environment.objects.create( + name="Target", project=project, use_v2_feature_versioning=True + ) + EnvironmentFeatureVersion.objects.filter( + environment=target_env, feature=existing_feature + ).delete() + existing_feature.feature_states.filter(environment=target_env).delete() + + payload = [ + { + "name": "3", + "default_enabled": True, + "is_server_key_only": False, + "initial_value": "imported", + "value": "imported_target", + "type": STRING, + "enabled": True, + "multivariate": [], + } + ] + feature_import = FeatureImport.objects.create( + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then live state reflects the import (the created FS is versioned and visible). + live_states = list( + FeatureState.objects.get_live_feature_states( + environment=target_env, + additional_filters=Q( + feature=existing_feature, + identity__isnull=True, + feature_segment__isnull=True, + ), + ) + ) + assert len(live_states) == 1 + live_fs = live_states[0] + assert live_fs.enabled is True + assert live_fs.feature_state_value.value == "imported_target" + + +def test_import_features_for_environment__overwrite_destructive_with_v2_versioning__updates_live_feature_state( + db: None, +) -> None: + # Given a v2-versioned environment where the feature has multiple published + # versions, so the initial-version FeatureState is no longer the live one. + organisation = Organisation.objects.create(name="Receiving") + project = Project.objects.create(name="Web", organisation=organisation) + target_env = Environment.objects.create( + name="Target", project=project, use_v2_feature_versioning=True + ) + existing_feature = Feature.objects.create( + name="3", project=project, initial_value="keepme" + ) + + second_version = EnvironmentFeatureVersion.objects.create( + environment=target_env, feature=existing_feature + ) + second_version.publish() + + payload = [ + { + "name": "3", + "default_enabled": True, + "is_server_key_only": False, + "initial_value": "imported", + "value": "imported_value", + "type": STRING, + "enabled": True, + "multivariate": [], + } + ] + feature_import = FeatureImport.objects.create( + environment=target_env, + strategy=OVERWRITE_DESTRUCTIVE, + data=json.dumps(payload), + ) + + # When + import_features_for_environment(feature_import.id) + + # Then live state — what `get_live_feature_states` returns — reflects the + # imported value and enabled flag. + live_states = list( + FeatureState.objects.get_live_feature_states( + environment=target_env, + additional_filters=Q( + feature=existing_feature, + identity__isnull=True, + feature_segment__isnull=True, + ), + ) + ) + assert len(live_states) == 1 + live_fs = live_states[0] + 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, settings: SettingsWrapper, From 9bf33531c8fe8d6edd40ab4294ee4ee83868dea2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 27 Apr 2026 17:33:37 +0100 Subject: [PATCH 5/9] refactor(Import/Export): Replace per-concern unit tests with end-to-end export+import tests beep boop --- api/features/import_export/services.py | 44 +- .../test_unit_features_import_export_tasks.py | 475 ++++-------------- 2 files changed, 116 insertions(+), 403 deletions(-) diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py index 18e4fde54c20..9aec56344724 100644 --- a/api/features/import_export/services.py +++ b/api/features/import_export/services.py @@ -1,5 +1,3 @@ -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 @@ -25,33 +23,30 @@ def overwrite_feature_for_environment( existing_feature.default_enabled = feature_data["default_enabled"] existing_feature.save() - FeatureSegment.objects.filter( - feature=existing_feature, environment=environment - ).delete() + # Identity overrides aren't versioned and live on the FeatureState directly. 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, + new_version: EnvironmentFeatureVersion | None = None + if environment.use_v2_feature_versioning: + draft_version = EnvironmentFeatureVersion.objects.create( + environment=environment, feature=existing_feature + ) + draft_version.feature_states.filter(feature_segment__isnull=False).delete() + feature_state = draft_version.feature_states.get( + identity__isnull=True, feature_segment__isnull=True + ) + new_version = draft_version + else: + FeatureSegment.objects.filter( + feature=existing_feature, environment=environment + ).delete() + feature_state = existing_feature.feature_states.get( + environment=environment, identity__isnull=True, feature_segment__isnull=True, - ), - ).first() - if feature_state is None: - fs_kwargs: dict[str, object] = { - "feature": existing_feature, - "environment": environment, - } - if environment.use_v2_feature_versioning: - fs_kwargs["environment_feature_version"] = ( - EnvironmentFeatureVersion.create_initial_version( - environment=environment, feature=existing_feature - ) - ) - feature_state = FeatureState.objects.create(**fs_kwargs) + ) existing_options_by_value: dict[ tuple[str | None, str | int | bool | None], MultivariateFeatureOption @@ -107,6 +102,9 @@ def overwrite_feature_for_environment( feature_state.enabled = feature_data["enabled"] feature_state.save() + if new_version is not None and not new_version.published: + new_version.publish() + def create_feature_for_environment( feature_data: FeatureExportData, 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 03125ce62501..ec343eeca25a 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 @@ -289,13 +289,51 @@ 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", ) + + # 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, @@ -376,188 +414,13 @@ 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" - -def test_import_features_for_environment__overwrite_destructive__leaves_other_environments_unchanged( - db: None, -) -> None: - # Given - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - target_env = Environment.objects.create(name="Target", project=project) - bystander_env = Environment.objects.create(name="Bystander", project=project) - - existing_feature = Feature.objects.create( - name="3", - project=project, - initial_value="keepme", - default_enabled=False, - ) - mv_option_1 = MultivariateFeatureOption.objects.create( - feature=existing_feature, - default_percentage_allocation=30, - type=STRING, - string_value="mv1", - ) - mv_option_2 = MultivariateFeatureOption.objects.create( - feature=existing_feature, - default_percentage_allocation=70, - type=STRING, - string_value="mv2", - ) - - bystander_fs = existing_feature.feature_states.get(environment=bystander_env) - 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_mv_value_1 = bystander_fs.multivariate_feature_state_values.get( - multivariate_feature_option=mv_option_1 - ) - bystander_mv_value_1.percentage_allocation = 25 - bystander_mv_value_1.save() - bystander_mv_value_2 = bystander_fs.multivariate_feature_state_values.get( - multivariate_feature_option=mv_option_2 - ) - bystander_mv_value_2.percentage_allocation = 75 - bystander_mv_value_2.save() - - feature_pk = existing_feature.pk - bystander_fs_pk = bystander_fs.pk - bystander_fsv_pk = bystander_fs.feature_state_value.pk - bystander_mv_value_1_pk = bystander_mv_value_1.pk - bystander_mv_value_2_pk = bystander_mv_value_2.pk - - payload = [ - { - "name": "3", - "default_enabled": True, - "is_server_key_only": False, - "initial_value": "imported", - "value": "imported_target_value", - "type": STRING, - "enabled": True, - "multivariate": [ - { - "percentage_allocation": 90, - "default_percentage_allocation": 30, - "value": "mv1", - "type": STRING, - }, - { - "percentage_allocation": 10, - "default_percentage_allocation": 70, - "value": "mv2", - "type": STRING, - }, - ], - } - ] - feature_import = FeatureImport.objects.create( - environment=target_env, - strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), - ) - - # When - import_features_for_environment(feature_import.id) - - # Then - existing_feature.refresh_from_db() - assert existing_feature.pk == feature_pk - + # 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.pk == bystander_fsv_pk assert bystander_fs.feature_state_value.value == "bystander_value" - bystander_mv_value_1.refresh_from_db() - bystander_mv_value_2.refresh_from_db() - assert bystander_mv_value_1.pk == bystander_mv_value_1_pk - assert bystander_mv_value_1.percentage_allocation == 25 - assert bystander_mv_value_2.pk == bystander_mv_value_2_pk - assert bystander_mv_value_2.percentage_allocation == 75 - - -def test_import_features_for_environment__overwrite_destructive__deletes_target_environment_segment_and_identity_overrides( - db: None, -) -> None: - # Given - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - target_env = Environment.objects.create(name="Target", project=project) - bystander_env = Environment.objects.create(name="Bystander", project=project) - segment = Segment.objects.create(name="Beta", project=project) - - existing_feature = Feature.objects.create( - name="3", - project=project, - initial_value="keepme", - ) - - target_segment = FeatureSegment.objects.create( - feature=existing_feature, segment=segment, environment=target_env - ) - target_segment_fs = FeatureState.objects.create( - feature=existing_feature, - environment=target_env, - feature_segment=target_segment, - ) - bystander_segment = FeatureSegment.objects.create( - feature=existing_feature, segment=segment, environment=bystander_env - ) - bystander_segment_fs = FeatureState.objects.create( - feature=existing_feature, - environment=bystander_env, - feature_segment=bystander_segment, - ) - - target_identity = Identity.objects.create( - identifier="target-id", environment=target_env - ) - target_identity_fs = FeatureState.objects.create( - feature=existing_feature, environment=target_env, identity=target_identity - ) - bystander_identity = Identity.objects.create( - identifier="bystander-id", environment=bystander_env - ) - bystander_identity_fs = FeatureState.objects.create( - feature=existing_feature, - environment=bystander_env, - identity=bystander_identity, - ) - - target_segment_pk = target_segment.pk - target_segment_fs_pk = target_segment_fs.pk - target_identity_fs_pk = target_identity_fs.pk - bystander_segment_pk = bystander_segment.pk - bystander_segment_fs_pk = bystander_segment_fs.pk - bystander_identity_fs_pk = bystander_identity_fs.pk - - payload = [ - { - "name": "3", - "default_enabled": False, - "is_server_key_only": False, - "initial_value": "imported", - "value": "imported", - "type": STRING, - "enabled": False, - "multivariate": [], - } - ] - feature_import = FeatureImport.objects.create( - environment=target_env, - strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), - ) - - # When - import_features_for_environment(feature_import.id) - - # Then + # 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 @@ -566,236 +429,87 @@ def test_import_features_for_environment__overwrite_destructive__deletes_target_ pk=target_identity_fs_pk, deleted_at__isnull=True ).exists() - assert FeatureSegment.objects.filter(pk=bystander_segment_pk).exists() - assert FeatureState.objects.filter( - pk=bystander_segment_fs_pk, deleted_at__isnull=True - ).exists() - assert FeatureState.objects.filter( - pk=bystander_identity_fs_pk, deleted_at__isnull=True - ).exists() - -def test_import_features_for_environment__overwrite_destructive_with_missing_target_feature_state__creates_environment_default( +def test_export_and_import_features__overwrite_destructive_with_v2_versioning__publishes_new_version_preserving_history( db: None, + environment: Environment, + project: Project, ) -> None: - # Given - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - bystander_env = Environment.objects.create(name="Bystander", project=project) - existing_feature = Feature.objects.create( - name="3", - project=project, - initial_value="keepme", + # Given a source env exporting a feature with a known value, and a target + # project whose v2-versioned env already has the overlapping feature with + # an established live version. + 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_env = Environment.objects.create(name="Target", project=project) - existing_feature.feature_states.filter(environment=target_env).delete() - - feature_pk = existing_feature.pk - bystander_fs_pk = existing_feature.feature_states.get(environment=bystander_env).pk - - payload = [ - { - "name": "3", - "default_enabled": True, - "is_server_key_only": False, - "initial_value": "imported", - "value": "imported_target", - "type": STRING, - "enabled": True, - "multivariate": [], - } - ] - feature_import = FeatureImport.objects.create( - environment=target_env, - strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), + target_feature = Feature.objects.create( + name="3", project=project2, initial_value="keepme" ) - # When - import_features_for_environment(feature_import.id) - - # Then - existing_feature.refresh_from_db() - assert existing_feature.pk == feature_pk - - target_fs_qs = existing_feature.feature_states.filter( - environment=target_env, - identity__isnull=True, - feature_segment__isnull=True, + original_version = EnvironmentFeatureVersion.objects.get( + environment=target_env, feature=target_feature ) - assert target_fs_qs.count() == 1 - target_fs = target_fs_qs.get() - assert target_fs.enabled is True - assert target_fs.feature_state_value.value == "imported_target" - - assert ( - existing_feature.feature_states.get(environment=bystander_env).pk - == bystander_fs_pk + original_fs = original_version.feature_states.get( + identity__isnull=True, feature_segment__isnull=True ) + original_fs.enabled = False + original_fs.save() + original_fs.feature_state_value.type = STRING + original_fs.feature_state_value.string_value = "original_value" + original_fs.feature_state_value.save() + original_version_pk = original_version.pk + original_fs_pk = original_fs.pk -def test_import_features_for_environment__overwrite_destructive_with_matching_feature__updates_target_environment_value_and_enabled( - db: None, -) -> None: - # Given - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - target_env = Environment.objects.create(name="Target", project=project) - existing_feature = Feature.objects.create( - name="3", - project=project, - initial_value="keepme", - default_enabled=False, - is_server_key_only=False, - ) - target_fs = existing_feature.feature_states.get(environment=target_env) - target_fs.enabled = False - target_fs.save() - target_fs.feature_state_value.type = STRING - target_fs.feature_state_value.string_value = "old_value" - target_fs.feature_state_value.save() - - feature_pk = existing_feature.pk - target_fs_pk = target_fs.pk - - payload = [ - { - "name": "3", - "default_enabled": True, - "is_server_key_only": True, - "initial_value": "imported_initial", - "value": "imported_value", - "type": STRING, - "enabled": True, - "multivariate": [], - } - ] - feature_import = FeatureImport.objects.create( - environment=target_env, - strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), + feature_export = FeatureExport.objects.create( + environment=environment, status=PROCESSING ) # When - import_features_for_environment(feature_import.id) - - # Then - existing_feature.refresh_from_db() - assert existing_feature.pk == feature_pk - assert existing_feature.initial_value == "imported_initial" - assert existing_feature.default_enabled is True - assert existing_feature.is_server_key_only is True - - target_fs.refresh_from_db() - assert target_fs.pk == target_fs_pk - assert target_fs.enabled is True - assert target_fs.feature_state_value.value == "imported_value" - + export_features_for_environment(feature_export.id) + feature_export.refresh_from_db() -def test_import_features_for_environment__overwrite_destructive_with_v2_versioning_and_missing_target_feature_state__creates_versioned_live_feature_state( - db: None, -) -> None: - # Given a v2-versioned environment where the feature has no FeatureState - # (simulating the legacy "feature predates env" case). - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - Environment.objects.create(name="Bystander", project=project) - existing_feature = Feature.objects.create( - name="3", project=project, initial_value="keepme" - ) - target_env = Environment.objects.create( - name="Target", project=project, use_v2_feature_versioning=True - ) - EnvironmentFeatureVersion.objects.filter( - environment=target_env, feature=existing_feature - ).delete() - existing_feature.feature_states.filter(environment=target_env).delete() - - payload = [ - { - "name": "3", - "default_enabled": True, - "is_server_key_only": False, - "initial_value": "imported", - "value": "imported_target", - "type": STRING, - "enabled": True, - "multivariate": [], - } - ] - feature_import = FeatureImport.objects.create( + feature_import = FeatureImport.objects.create( # type: ignore[misc] environment=target_env, strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), + data=feature_export.data, ) - - # When import_features_for_environment(feature_import.id) - # Then live state reflects the import (the created FS is versioned and visible). - live_states = list( - FeatureState.objects.get_live_feature_states( - environment=target_env, - additional_filters=Q( - feature=existing_feature, - identity__isnull=True, - feature_segment__isnull=True, - ), - ) + # Then a new published version exists, the original version is unchanged, + # and live state reflects the import. + versions = EnvironmentFeatureVersion.objects.filter( + environment=target_env, feature=target_feature ) - assert len(live_states) == 1 - live_fs = live_states[0] - assert live_fs.enabled is True - assert live_fs.feature_state_value.value == "imported_target" + assert versions.count() == 2 + original_version.refresh_from_db() + assert original_version.pk == original_version_pk -def test_import_features_for_environment__overwrite_destructive_with_v2_versioning__updates_live_feature_state( - db: None, -) -> None: - # Given a v2-versioned environment where the feature has multiple published - # versions, so the initial-version FeatureState is no longer the live one. - organisation = Organisation.objects.create(name="Receiving") - project = Project.objects.create(name="Web", organisation=organisation) - target_env = Environment.objects.create( - name="Target", project=project, use_v2_feature_versioning=True - ) - existing_feature = Feature.objects.create( - name="3", project=project, initial_value="keepme" - ) - - second_version = EnvironmentFeatureVersion.objects.create( - environment=target_env, feature=existing_feature - ) - second_version.publish() - - payload = [ - { - "name": "3", - "default_enabled": True, - "is_server_key_only": False, - "initial_value": "imported", - "value": "imported_value", - "type": STRING, - "enabled": True, - "multivariate": [], - } - ] - feature_import = FeatureImport.objects.create( - environment=target_env, - strategy=OVERWRITE_DESTRUCTIVE, - data=json.dumps(payload), - ) + original_fs.refresh_from_db() + assert original_fs.pk == original_fs_pk + assert original_fs.enabled is False + assert original_fs.feature_state_value.value == "original_value" - # When - import_features_for_environment(feature_import.id) + new_version = versions.exclude(pk=original_version_pk).get() + assert new_version.published_at is not None - # Then live state — what `get_live_feature_states` returns — reflects the - # imported value and enabled flag. live_states = list( FeatureState.objects.get_live_feature_states( environment=target_env, additional_filters=Q( - feature=existing_feature, + feature=target_feature, identity__isnull=True, feature_segment__isnull=True, ), @@ -803,6 +517,7 @@ def test_import_features_for_environment__overwrite_destructive_with_v2_versioni ) assert len(live_states) == 1 live_fs = live_states[0] + assert live_fs.environment_feature_version_id == new_version.uuid assert live_fs.enabled is True assert live_fs.feature_state_value.value == "imported_value" From e846b5f7d75f90a9e567aedc2c0e0e7abc5d9039 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 11:49:39 +0100 Subject: [PATCH 6/9] refactor(Import/Export): Drop new-version-on-destructive-import; mutate live version in place beep boop --- api/features/import_export/services.py | 33 +++++-------- .../test_unit_features_import_export_tasks.py | 49 +++++++++---------- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py index 9aec56344724..65e7ce1dc1dd 100644 --- a/api/features/import_export/services.py +++ b/api/features/import_export/services.py @@ -1,3 +1,5 @@ +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 @@ -5,7 +7,6 @@ MultivariateFeatureOption, MultivariateFeatureStateValue, ) -from features.versioning.models import EnvironmentFeatureVersion from projects.models import Project @@ -23,30 +24,21 @@ def overwrite_feature_for_environment( existing_feature.default_enabled = feature_data["default_enabled"] existing_feature.save() - # Identity overrides aren't versioned and live on the FeatureState directly. + FeatureSegment.objects.filter( + feature=existing_feature, environment=environment + ).delete() existing_feature.feature_states.filter( environment=environment, identity__isnull=False ).delete() - new_version: EnvironmentFeatureVersion | None = None - if environment.use_v2_feature_versioning: - draft_version = EnvironmentFeatureVersion.objects.create( - environment=environment, feature=existing_feature - ) - draft_version.feature_states.filter(feature_segment__isnull=False).delete() - feature_state = draft_version.feature_states.get( - identity__isnull=True, feature_segment__isnull=True - ) - new_version = draft_version - else: - FeatureSegment.objects.filter( - feature=existing_feature, environment=environment - ).delete() - feature_state = existing_feature.feature_states.get( - environment=environment, + 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 @@ -102,9 +94,6 @@ def overwrite_feature_for_environment( feature_state.enabled = feature_data["enabled"] feature_state.save() - if new_version is not None and not new_version.published: - new_version.publish() - def create_feature_for_environment( feature_data: FeatureExportData, 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 ec343eeca25a..6a38bdd8ecdb 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 @@ -430,14 +430,14 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov ).exists() -def test_export_and_import_features__overwrite_destructive_with_v2_versioning__publishes_new_version_preserving_history( +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 - # project whose v2-versioned env already has the overlapping feature with - # an established live version. + # 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" ) @@ -457,20 +457,24 @@ def test_export_and_import_features__overwrite_destructive_with_v2_versioning__p name="3", project=project2, initial_value="keepme" ) - original_version = EnvironmentFeatureVersion.objects.get( + initial_version = EnvironmentFeatureVersion.objects.get( environment=target_env, feature=target_feature ) - original_fs = original_version.feature_states.get( + initial_fs = initial_version.feature_states.get( identity__isnull=True, feature_segment__isnull=True ) - original_fs.enabled = False - original_fs.save() - original_fs.feature_state_value.type = STRING - original_fs.feature_state_value.string_value = "original_value" - original_fs.feature_state_value.save() + initial_fs.feature_state_value.type = STRING + initial_fs.feature_state_value.string_value = "initial_value" + initial_fs.feature_state_value.save() - original_version_pk = original_version.pk - original_fs_pk = original_fs.pk + 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 @@ -487,23 +491,18 @@ def test_export_and_import_features__overwrite_destructive_with_v2_versioning__p ) import_features_for_environment(feature_import.id) - # Then a new published version exists, the original version is unchanged, - # and live state reflects the import. + # 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} - original_version.refresh_from_db() - assert original_version.pk == original_version_pk - - original_fs.refresh_from_db() - assert original_fs.pk == original_fs_pk - assert original_fs.enabled is False - assert original_fs.feature_state_value.value == "original_value" - - new_version = versions.exclude(pk=original_version_pk).get() - assert new_version.published_at is not None + 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( @@ -517,7 +516,7 @@ def test_export_and_import_features__overwrite_destructive_with_v2_versioning__p ) assert len(live_states) == 1 live_fs = live_states[0] - assert live_fs.environment_feature_version_id == new_version.uuid + 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" From e27a0ac1fc28e515bcd286c2bfa861e0b6f0fd1d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 12:12:15 +0100 Subject: [PATCH 7/9] test(Import/Export): Cover destructive multivariate reconciliation beep boop --- api/features/import_export/services.py | 20 ++--- .../test_unit_features_import_export_tasks.py | 85 ++++++++++++++++++- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/api/features/import_export/services.py b/api/features/import_export/services.py index 65e7ce1dc1dd..6ce5567c31ed 100644 --- a/api/features/import_export/services.py +++ b/api/features/import_export/services.py @@ -3,10 +3,7 @@ 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, - MultivariateFeatureStateValue, -) +from features.multivariate.models import MultivariateFeatureOption from projects.models import Project @@ -63,18 +60,11 @@ def overwrite_feature_for_environment( ) mv_option.save() imported_option_ids.add(mv_option.pk) - mv_state_value = feature_state.multivariate_feature_state_values.filter( + mv_state_value = feature_state.multivariate_feature_state_values.get( multivariate_feature_option=mv_option, - ).first() - if mv_state_value is None: - MultivariateFeatureStateValue.objects.create( - feature_state=feature_state, - multivariate_feature_option=mv_option, - percentage_allocation=mv_data["percentage_allocation"], - ) - else: - mv_state_value.percentage_allocation = mv_data["percentage_allocation"] - mv_state_value.save() + ) + 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, 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 6a38bdd8ecdb..36a0a0766e7f 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 @@ -237,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, @@ -299,6 +312,46 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov 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 @@ -362,11 +415,27 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov 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, attrs replaced + # by the import. + assert new_feature2.pk == overlapping_feature_2_pk assert new_feature2.type == MULTIVARIATE assert new_feature2.initial_value == "banana" assert new_feature2.is_server_key_only is False @@ -379,13 +448,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 @@ -393,8 +470,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 From f85ac67ca30a916d7b6b15814a5c90021d708ad5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 16:09:10 +0100 Subject: [PATCH 8/9] refactor(Import/Export): Drop create_feature_for_environment; orchestrate via mapper + overwrite beep boop --- api/features/import_export/mappers.py | 15 ++++++ api/features/import_export/services.py | 52 +------------------ api/features/import_export/tasks.py | 22 +++----- .../test_unit_features_import_export_tasks.py | 9 ++-- 4 files changed, 29 insertions(+), 69 deletions(-) create mode 100644 api/features/import_export/mappers.py 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 index 6ce5567c31ed..4e31b6f35ae6 100644 --- a/api/features/import_export/services.py +++ b/api/features/import_export/services.py @@ -4,7 +4,6 @@ from features.import_export.types import FeatureExportData from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureOption -from projects.models import Project def overwrite_feature_for_environment( @@ -14,13 +13,8 @@ def overwrite_feature_for_environment( ) -> None: """ Apply a destructive feature import to a single environment without - affecting other environments' feature states. + affecting other environments' feature states or the Feature definition. """ - existing_feature.initial_value = feature_data["initial_value"] - existing_feature.is_server_key_only = feature_data["is_server_key_only"] - existing_feature.default_enabled = feature_data["default_enabled"] - existing_feature.save() - FeatureSegment.objects.filter( feature=existing_feature, environment=environment ).delete() @@ -83,47 +77,3 @@ def overwrite_feature_for_environment( feature_state_value.save() feature_state.enabled = feature_data["enabled"] feature_state.save() - - -def create_feature_for_environment( - feature_data: FeatureExportData, - 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"]: - mv_feature_option = MultivariateFeatureOption( - feature=feature, - default_percentage_allocation=mv_data["default_percentage_allocation"], - type=mv_data["type"], - ) - setattr( - mv_feature_option, - FeatureState.get_feature_state_key_name(mv_data["type"]), - mv_data["value"], - ) - mv_feature_option.save() - 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"] - mv_feature_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 1798c77e5589..cdfe7cfa6fef 100644 --- a/api/features/import_export/tasks.py +++ b/api/features/import_export/tasks.py @@ -12,20 +12,17 @@ from features.import_export.constants import ( FAILED, - OVERWRITE_DESTRUCTIVE, 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 ( - create_feature_for_environment, - overwrite_feature_for_environment, -) +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 @@ -156,17 +153,14 @@ def _import_features_for_environment(feature_import: FeatureImport) -> None: project=project, ).first() - if existing_feature: - if feature_import.strategy == SKIP: - continue + if existing_feature and feature_import.strategy == SKIP: + continue - if feature_import.strategy == OVERWRITE_DESTRUCTIVE: - overwrite_feature_for_environment( - feature_data, existing_feature, environment - ) - continue + if existing_feature is None: + existing_feature = map_feature_export_data_to_feature(feature_data, project) + existing_feature.save() - create_feature_for_environment(feature_data, project, environment) + overwrite_feature_for_environment(feature_data, existing_feature, environment) feature_import.status = SUCCESS feature_import.save() 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 36a0a0766e7f..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 @@ -433,11 +433,12 @@ def test_export_and_import_features__overwrite_destructive_strategy__replaces_ov ) assert new_feature1_mv_value.percentage_allocation == 65 - # Overlapping MV feature was updated in place: pk preserved, attrs replaced - # by the import. + # 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 @@ -483,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, From df5a068a10601508f65bab4d6cbb2453c8e0024c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 16:17:15 +0100 Subject: [PATCH 9/9] docs(Import/Export): Update destructive-import warnings to reflect env-scoped semantics beep boop --- .../data-management/bulk-import-and-export.md | 4 ++-- frontend/web/components/import-export/FeatureImport.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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' && ( )}