diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b30ea4941a6d..1ed3e0d696a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,6 +37,7 @@ repos: args: - --check types: [javascript, jsx, ts, tsx, markdown, mdx, html, css, json, yaml] + - id: python-typecheck name: python-typecheck language: system diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 41d24a404653..f60776a8dc91 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1124,6 +1124,7 @@ FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = env( "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", default=FLAGSMITH_ON_FLAGSMITH_API_URL ) +USE_GLOBAL_FLAGSMITH_CLIENTS = True FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = env.int( "FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID", diff --git a/api/app/settings/test.py b/api/app/settings/test.py index f7b18a29e649..402319882bdd 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -65,3 +65,5 @@ ENABLE_POSTPONE_DECORATOR = False DEBUG = True + +USE_GLOBAL_FLAGSMITH_CLIENTS = False diff --git a/api/environments/models.py b/api/environments/models.py index b01b15c89453..07bc6029bb8c 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -1,3 +1,4 @@ +import datetime import logging import typing import uuid @@ -16,6 +17,7 @@ AFTER_DELETE, AFTER_SAVE, AFTER_UPDATE, + BEFORE_CREATE, LifecycleModel, hook, ) @@ -42,6 +44,7 @@ from environments.managers import EnvironmentManager from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue +from integrations.flagsmith.client import get_client from metadata.models import Metadata from projects.models import Project from segments.models import Segment @@ -165,6 +168,22 @@ def delete_from_dynamo(self): delete_environment_from_dynamo.delay(args=(self.api_key, self.id)) + @hook(BEFORE_CREATE) + def enable_v2_versioning(self): + flagsmith_client = get_client("local", local_eval=True) + organisation = self.project.organisation + flag = flagsmith_client.get_identity_flags( + organisation.flagsmith_identifier, + traits={"organisation_id": organisation.id}, + ).get_flag("enable_feature_versioning_for_new_projects") + + if ( + flag.enabled + and self.project.created_date.date() + >= datetime.date.fromisoformat(flag.value) + ): + self.use_v2_feature_versioning = True + def __str__(self): return "Project %s - Environment %s" % (self.project.name, self.name) diff --git a/api/integrations/flagsmith/client.py b/api/integrations/flagsmith/client.py index ffb518592dba..ddad99dc0d72 100644 --- a/api/integrations/flagsmith/client.py +++ b/api/integrations/flagsmith/client.py @@ -25,13 +25,15 @@ def get_client(name: str = "default", local_eval: bool = False) -> Flagsmith: global _flagsmith_clients - try: - _flagsmith_client = _flagsmith_clients[name] - except (KeyError, TypeError): - kwargs = _get_client_kwargs() - kwargs["enable_local_evaluation"] = local_eval - _flagsmith_client = Flagsmith(**kwargs) - _flagsmith_clients[name] = _flagsmith_client + if settings.USE_GLOBAL_FLAGSMITH_CLIENTS and ( + client := _flagsmith_clients.get(name) + ): + return client + + kwargs = _get_client_kwargs() + kwargs["enable_local_evaluation"] = local_eval + _flagsmith_client = Flagsmith(**kwargs) + _flagsmith_clients[name] = _flagsmith_client return _flagsmith_client diff --git a/api/tests/types.py b/api/tests/types.py index d66431ad21e5..9c402496895a 100644 --- a/api/tests/types.py +++ b/api/tests/types.py @@ -1,3 +1,4 @@ +import typing from typing import Callable, Literal from environments.permissions.models import UserEnvironmentPermission @@ -15,3 +16,9 @@ ] AdminClientAuthType = Literal["user", "master_api_key"] + + +class TestFlagData(typing.NamedTuple): + feature_name: str + enabled: bool + value: typing.Any diff --git a/api/tests/unit/conftest.py b/api/tests/unit/conftest.py index 294b7e1df863..e4680fe44ad0 100644 --- a/api/tests/unit/conftest.py +++ b/api/tests/unit/conftest.py @@ -1,10 +1,20 @@ +import typing + import pytest +from flag_engine.environments.models import EnvironmentModel +from flag_engine.features.models import FeatureModel, FeatureStateModel +from flag_engine.organisations.models import OrganisationModel +from flag_engine.projects.models import ProjectModel +from flagsmith.offline_handlers import BaseOfflineHandler +from pytest_mock import MockerFixture from environments.models import Environment +from features.feature_types import STANDARD from features.models import Feature from organisations.models import Organisation, OrganisationRole from projects.models import Project from projects.tags.models import Tag +from tests.types import TestFlagData from users.models import FFAdminUser @@ -200,3 +210,53 @@ def project_two_feature(project_two: Project) -> Feature: return Feature.objects.create( name="project_two_feature", project=project_two, initial_value="initial_value" ) + + +@pytest.fixture() +def set_flagsmith_client_flags( + mocker: MockerFixture, +) -> typing.Callable[[list[TestFlagData]], None]: + class TestOfflineHandler(BaseOfflineHandler): + def __init__(self): + self.environment = EnvironmentModel( + id=1, + api_key="flagsmith-environment-key", + project=ProjectModel( + id=1, + name="flagsmith-project", + organisation=OrganisationModel( + id=1, + name="flagsmith-organisation", + feature_analytics=False, + stop_serving_flags=False, + persist_trait_data=False, + ), + hide_disabled_flags=False, + ), + feature_states=[], + ) + + def set_flags(self, flags: list[TestFlagData]) -> None: + self.environment.feature_states = [ + FeatureStateModel( + feature=FeatureModel( + id=i, name=flag_data.feature_name, type=STANDARD + ), + enabled=flag_data.enabled, + feature_state_value=flag_data.value, + ) + for i, flag_data in enumerate(flags) + ] + + def get_environment(self) -> EnvironmentModel: + return self.environment + + offline_handler = TestOfflineHandler() + mocker.patch( + "integrations.flagsmith.client.LocalFileHandler", return_value=offline_handler + ) + + def _setter(flags: list[TestFlagData]) -> None: + offline_handler.set_flags(flags) + + return _setter diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index 82c1ab0fe3b6..2faac6735863 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -34,6 +34,7 @@ from organisations.models import Organisation, OrganisationRole from projects.models import EdgeV2MigrationStatus, Project from segments.models import Segment +from tests.types import TestFlagData from util.mappers import map_environment_to_environment_document if typing.TYPE_CHECKING: @@ -1021,3 +1022,66 @@ def test_environment_clone_async( "clone_environment_id": cloned_environment.id, } ) + + +def test_environment_create_with_use_v2_feature_versioning_true( + project: Project, + environment_v2_versioning: Environment, + feature: Feature, + set_flagsmith_client_flags: typing.Callable[[list[TestFlagData]], None], +) -> None: + # Given + set_flagsmith_client_flags( + [TestFlagData("enable_feature_versioning_for_new_projects", True, "2025-02-17")] + ) + + # When + new_environment = Environment.objects.create( + name="new-environment", + project=project, + ) + + # Then + assert EnvironmentFeatureVersion.objects.filter( + environment=new_environment, feature=feature + ).exists() + + +def test_environment_clone_from_versioned_environment_with_use_v2_feature_versioning_true( + project: Project, + environment_v2_versioning: Environment, + feature: Feature, + set_flagsmith_client_flags: typing.Callable[[list[TestFlagData]], None], +) -> None: + # Given + set_flagsmith_client_flags( + [TestFlagData("enable_feature_versioning_for_new_projects", True, "2025-02-17")] + ) + + # When + new_environment = environment_v2_versioning.clone(name="new-environment") + + # Then + assert EnvironmentFeatureVersion.objects.filter( + environment=new_environment, feature=feature + ).exists() + + +def test_environment_clone_from_non_versioned_environment_with_use_v2_feature_versioning_true( + project: Project, + environment: Environment, + feature: Feature, + set_flagsmith_client_flags: typing.Callable[[list[TestFlagData]], None], +) -> None: + # Given + set_flagsmith_client_flags( + [TestFlagData("enable_feature_versioning_for_new_projects", True, "2025-02-17")] + ) + + # When + new_environment = environment.clone(name="new-environment") + + # Then + assert not EnvironmentFeatureVersion.objects.filter( + environment=new_environment, feature=feature + ).exists()