From eef02fb75de85a75b169561b9055b533f3c71bfb Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 21 May 2024 22:50:26 +0100 Subject: [PATCH] fix(versioning): fix cloning environments using v2 versioning (#3999) --- api/environments/models.py | 28 ++++++++- api/features/versioning/managers.py | 20 ++++++- api/features/versioning/models.py | 12 ++++ .../test_unit_environments_models.py | 57 +++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/api/environments/models.py b/api/environments/models.py index 543e8a547276..1c592c42cc1a 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -41,6 +41,7 @@ from environments.managers import EnvironmentManager from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue +from features.versioning.models import EnvironmentFeatureVersion from metadata.models import Metadata from projects.models import Project from segments.models import Segment @@ -174,8 +175,31 @@ def clone(self, name: str, api_key: str = None) -> "Environment": # Since identities are closely tied to the environment # it does not make much sense to clone them, hence # only clone feature states without identities - for feature_state in self.feature_states.filter(identity=None): - feature_state.clone(clone, live_from=feature_state.live_from) + queryset = self.feature_states.filter(identity=None) + + if self.use_v2_feature_versioning: + # Grab the latest feature versions from the source environment. + latest_environment_feature_versions = ( + EnvironmentFeatureVersion.objects.get_latest_versions_as_queryset( + environment=self + ) + ) + + # Create a dictionary holding the environment feature versions (unique per feature) + # to use in the cloned environment. + clone_environment_feature_versions = { + efv.feature_id: efv.clone_to_environment(environment=clone) + for efv in latest_environment_feature_versions + } + + for feature_state in queryset.filter( + environment_feature_version__in=latest_environment_feature_versions + ): + clone_efv = clone_environment_feature_versions[feature_state.feature_id] + feature_state.clone(clone, environment_feature_version=clone_efv) + else: + for feature_state in queryset: + feature_state.clone(clone, live_from=feature_state.live_from) return clone diff --git a/api/features/versioning/managers.py b/api/features/versioning/managers.py index 32c41ad0a831..07b2a7a3959f 100644 --- a/api/features/versioning/managers.py +++ b/api/features/versioning/managers.py @@ -1,11 +1,12 @@ import typing from pathlib import Path -from django.db.models.query import RawQuerySet +from django.db.models.query import QuerySet, RawQuerySet from softdelete.models import SoftDeleteManager if typing.TYPE_CHECKING: from environments.models import Environment + from features.versioning.models import EnvironmentFeatureVersion with open(Path(__file__).parent.resolve() / "sql/get_latest_versions.sql") as f: @@ -15,9 +16,22 @@ class EnvironmentFeatureVersionManager(SoftDeleteManager): def get_latest_versions(self, environment: "Environment") -> RawQuerySet: """ - Get the latest EnvironmentFeatureVersion objects - for a given environment. + Get the latest EnvironmentFeatureVersion objects for a given environment. """ return self.raw( get_latest_versions_sql, params={"environment_id": environment.id} ) + + def get_latest_versions_as_queryset( + self, environment: "Environment" + ) -> QuerySet["EnvironmentFeatureVersion"]: + """ + Get the latest EnvironmentFeatureVersion objects for a given environment + as a concrete QuerySet. + + Note that it is often required to return the proper QuerySet to carry out + operations on the ORM object. + """ + return self.filter( + uuid__in=[efv.uuid for efv in self.get_latest_versions(environment)] + ) diff --git a/api/features/versioning/models.py b/api/features/versioning/models.py index cd885ba4d917..c38c15d23602 100644 --- a/api/features/versioning/models.py +++ b/api/features/versioning/models.py @@ -1,6 +1,7 @@ import datetime import typing import uuid +from copy import deepcopy from core.models import ( SoftDeleteExportableModel, @@ -128,3 +129,14 @@ def publish( if persist: self.save() environment_feature_version_published.send(self.__class__, instance=self) + + def clone_to_environment( + self, environment: "Environment" + ) -> "EnvironmentFeatureVersion": + _clone = deepcopy(self) + + _clone.uuid = None + _clone.environment = environment + + _clone.save() + return _clone diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index f1c263b1d1d8..046e7a899bba 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -27,6 +27,10 @@ from features.models import Feature, FeatureState from features.multivariate.models import MultivariateFeatureOption from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.tasks import enable_v2_versioning +from features.versioning.versioning_service import ( + get_environment_flags_queryset, +) from organisations.models import Organisation, OrganisationRole from projects.models import EdgeV2MigrationStatus, Project from segments.models import Segment @@ -937,3 +941,56 @@ def test_create_environment_creates_feature_states_in_all_environments_and_envir EnvironmentFeatureVersion.objects.filter(environment=environment).count() == 2 ) assert environment.feature_states.count() == 2 + + +def test_clone_environment_v2_versioning( + feature: Feature, + feature_state: FeatureState, + segment: Segment, + segment_featurestate: FeatureState, + environment: Environment, +) -> None: + # Given + expected_environment_fs_enabled_value = True + expected_segment_fs_enabled_value = True + + # First let's create some new versions via the old versioning methods + feature_state.clone(environment, version=2) + feature_state.clone(environment, version=3) + + # and a draft version + feature_state.clone(environment, as_draft=True) + + # Now let's enable v2 versioning for the environment + enable_v2_versioning(environment.id) + environment.refresh_from_db() + + # Finally, let's create another version using the new versioning methods + # and update some values on the feature states in it. + v2 = EnvironmentFeatureVersion.objects.create( + feature=feature, environment=environment + ) + v2.feature_states.filter(feature_segment__isnull=True).update( + enabled=expected_environment_fs_enabled_value + ) + v2.feature_states.filter(feature_segment__isnull=False).update( + enabled=expected_segment_fs_enabled_value + ) + v2.publish() + + # When + cloned_environment = environment.clone(name="Cloned environment") + + # Then + assert cloned_environment.use_v2_feature_versioning is True + + cloned_environment_flags = get_environment_flags_queryset(cloned_environment) + + assert ( + cloned_environment_flags.get(feature_segment__isnull=True).enabled + is expected_environment_fs_enabled_value + ) + assert ( + cloned_environment_flags.get(feature_segment__segment=segment).enabled + is expected_segment_fs_enabled_value + )