From 3ac5eea0f83070e0ce6bd8969a2532d1f83dbea2 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 3 Jul 2026 19:44:30 -0300 Subject: [PATCH 1/9] red stuff --- api/tests/integration/conftest.py | 10 +- .../versioning/test_update_flag_endpoints.py | 351 ++++++++++++++++++ 2 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 api/tests/integration/features/versioning/test_update_flag_endpoints.py diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 1adc9aca6f81..4b7b74c69d08 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -17,6 +17,7 @@ from environments.enums import EnvironmentDocumentCacheMode from organisations.models import Organisation from tests.integration.helpers import create_mv_option_with_api +from users.models import FFAdminUser @pytest.fixture() @@ -30,12 +31,12 @@ def django_client(): # type: ignore[no-untyped-def] @pytest.fixture() -def api_client(): # type: ignore[no-untyped-def] +def api_client() -> APIClient: return APIClient() @pytest.fixture() -def admin_client(api_client, admin_user): # type: ignore[no-untyped-def] +def admin_client(api_client: APIClient, admin_user: FFAdminUser) -> APIClient: api_client.force_authenticate(user=admin_user) return api_client @@ -104,11 +105,6 @@ def environment_api_key() -> str: return create_hash() -@pytest.fixture() -def environment_api_key_str(environment_api_key: str) -> str: - return environment_api_key - - @pytest.fixture() def environment_name() -> str: return "Test Environment" diff --git a/api/tests/integration/features/versioning/test_update_flag_endpoints.py b/api/tests/integration/features/versioning/test_update_flag_endpoints.py new file mode 100644 index 000000000000..978ee10f522e --- /dev/null +++ b/api/tests/integration/features/versioning/test_update_flag_endpoints.py @@ -0,0 +1,351 @@ +"""https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags""" + +from collections.abc import Callable +from typing import Any + +import pytest +from rest_framework.test import APIClient + +from environments.models import Environment +from features.models import FeatureState +from features.multivariate.models import MultivariateFeatureOption +from features.versioning.tasks import enable_v2_versioning +from tests.integration.helpers import create_mv_option_with_api + +type FeatureUpdatePayload = dict[str, Any] + + +@pytest.fixture(params=["feature_versioning_v1", "feature_versioning_v2"], autouse=True) +def versioned_environment( + request: pytest.FixtureRequest, + environment: int, +) -> Environment: + if request.param == "feature_versioning_v2": + enable_v2_versioning(environment_id=environment) + return Environment.objects.get(id=environment) # type: ignore[no-any-return] + + +@pytest.mark.parametrize( + "endpoint,payload", + [ + pytest.param( + "update-flag-v1", + lambda feature: { + "feature": {"id": feature}, + "value": {"type": "string", "value": "control"}, + "multivariate_options": [ + { + "percentage_allocation": 50, + "value": {"type": "string", "value": "half"}, + }, + { + "percentage_allocation": 10, + "value": {"type": "string", "value": "bit more"}, + }, + ], + }, + id="option_a", + ), + pytest.param( + "update-flag-v2", + lambda feature: { + "feature": {"id": feature}, + "environment_default": { + "value": {"type": "string", "value": "control"}, + "multivariate_options": [ + { + "percentage_allocation": 50, + "value": {"type": "string", "value": "half"}, + }, + { + "percentage_allocation": 10, + "value": {"type": "string", "value": "bit more"}, + }, + ], + }, + }, + id="option_b", + ), + ], +) +def test_update_flag__environment_defaults__adds_multivariate_options( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, + endpoint: str, + payload: Callable[[int], FeatureUpdatePayload], +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature), + format="json", + ) + + # Then + assert response.status_code == 204 + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ) + .get() + .multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", "percentage_allocation" + ) + ) == {"half": 50, "bit more": 10} + + +@pytest.mark.parametrize( + "endpoint,payload", + [ + pytest.param( + "update-flag-v1", + lambda feature, option: { + "feature": {"id": feature}, + "multivariate_options": [ + { + "id": option, + "percentage_allocation": 25, + "value": {"type": "string", "value": "halfer"}, + }, + ], + }, + id="option_a", + ), + pytest.param( + "update-flag-v2", + lambda feature, option: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [ + { + "id": option, + "percentage_allocation": 25, + "value": {"type": "string", "value": "halfer"}, + }, + ], + }, + }, + id="option_b", + ), + ], +) +def test_update_flag__environment_defaults__updates_multivariate_options( + admin_client: APIClient, + environment_api_key: str, + feature: int, + mv_option_50_percent: int, + versioned_environment: Environment, + endpoint: str, + payload: Callable[[int, int], FeatureUpdatePayload], +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature, mv_option_50_percent), + format="json", + ) + + # Then + assert response.status_code == 204 + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ) + .get() + .multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", "percentage_allocation" + ) + ) == {"halfer": 25} + + +@pytest.mark.parametrize( + "endpoint,payload", + [ + pytest.param( + "update-flag-v1", + lambda feature, segment, option: { + "feature": {"id": feature}, + "segment": {"id": segment}, + "multivariate_options": [ + {"id": option, "percentage_allocation": 80}, + ], + }, + id="option_a", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment, option: { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "multivariate_options": [ + {"id": option, "percentage_allocation": 80}, + ], + }, + ], + }, + id="option_b", + ), + ], +) +def test_update_flag__segment_override__updates_multivariate_options( + admin_client: APIClient, + environment_api_key: str, + feature: int, + mv_option_value: str, + mv_option_50_percent: int, + segment: int, + # segment_featurestate: int, + versioned_environment: Environment, + endpoint: str, + payload: Callable[[int, int, int], FeatureUpdatePayload], +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature, segment, mv_option_50_percent), + format="json", + ) + + # Then + assert response.status_code == 204 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert dict( + live_feature_states.get( + feature_segment__segment_id=segment + ).multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", "percentage_allocation" + ) + ) == {mv_option_value: 80} + assert dict( + live_feature_states.get( + feature_segment=None + ).multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", "percentage_allocation" + ) + ) == {mv_option_value: 50} + + +@pytest.mark.parametrize( + "endpoint,payload", + [ + pytest.param( + "update-flag-v1", + lambda feature, kept_option: { + "feature": {"id": feature}, + "multivariate_options": [ + {"id": kept_option, "percentage_allocation": 50}, + ], + }, + id="option_a", + ), + pytest.param( + "update-flag-v2", + lambda feature, kept_option: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [ + {"id": kept_option, "percentage_allocation": 50}, + ], + }, + }, + id="option_b", + ), + ], +) +def test_update_flag__environment_defaults__deletes_multivariate_options( + admin_client: APIClient, + environment_api_key: str, + project: int, + feature: int, + mv_option_50_percent: int, + endpoint: str, + payload: Callable[[int, int], FeatureUpdatePayload], +) -> None: + # Given + deleted_option = create_mv_option_with_api( + admin_client, project, feature, 40, "other_mv_value" + ) + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature, mv_option_50_percent), + format="json", + ) + + # Then + assert response.status_code == 204 + assert not MultivariateFeatureOption.objects.filter(id=deleted_option).exists() + assert MultivariateFeatureOption.objects.filter(id=mv_option_50_percent).exists() + + +@pytest.mark.parametrize( + "endpoint,payload", + [ + pytest.param( + "update-flag-v1", + lambda feature: { + "feature": {"id": feature}, + "multivariate_options": [ + { + "percentage_allocation": 60, + "value": {"type": "string", "value": "more"}, + }, + { + "percentage_allocation": 50, + "value": {"type": "string", "value": "less"}, + }, + ], + }, + id="option_a", + ), + pytest.param( + "update-flag-v2", + lambda feature: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [ + { + "percentage_allocation": 60, + "value": {"type": "string", "value": "more"}, + }, + { + "percentage_allocation": 50, + "value": {"type": "string", "value": "less"}, + }, + ], + }, + }, + id="option_b", + ), + ], +) +def test_update_flag__multivariate_percentage_allocation_exceeds_100__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + endpoint: str, + payload: Callable[[int], FeatureUpdatePayload], +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature), + format="json", + ) + + # Then + assert response.status_code == 400 + assert "multivariate_options" in response.json() + assert not MultivariateFeatureOption.objects.filter(feature_id=feature).exists() From 589d534a8b3f5b9a8dc894b8276a5ae31ebf9c58 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 7 Jul 2026 22:58:45 -0300 Subject: [PATCH 2/9] update docs --- .../admin-api/updating-flags.md | 188 ++++++++++++++++-- 1 file changed, 167 insertions(+), 21 deletions(-) diff --git a/docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md b/docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md index 6a8ca75fbfb1..d60048f2bcaa 100644 --- a/docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md +++ b/docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md @@ -4,14 +4,17 @@ sidebar_label: Updating Flags (Experimental) sidebar_position: 3 --- -These experimental endpoints let you update feature flag values and segment overrides via the Admin API. They're -purpose-built for automation and CI/CD — minimal payloads, no need to look up internal IDs, and they work the same -regardless of whether your environment has Feature Versioning enabled. +These experimental endpoints let you update feature flag values and segment overrides via the Admin API. They're meant +as a simpler alternative to the current endpoints, which were designed with our dashboard user experience in mind: + +- Convenient to use in automation via CLI +- Need no prior feature ID lookup — accepts name +- Work the same regardless of Feature Versioning :::caution -These endpoints are experimental and may change without notice. They do not support multivariate values and cannot be -used when [change requests](/administration-and-security/governance-and-compliance/change-requests) are enabled. +These endpoints are experimental and may change without notice. They cannot be used when +[change requests](/administration-and-security/governance-and-compliance/change-requests) are enabled. ::: @@ -25,11 +28,11 @@ in one request). Each scenario below shows both. Try them and - All endpoints return **204 No Content** on success. - Values are passed as a `value` object with `type` and `value` (always a string): -| Type | Example | -| --------- | ------------------------------------------------ | -| `string` | `{"type": "string", "value": "hello"}` | -| `integer` | `{"type": "integer", "value": "42"}` | -| `boolean` | `{"type": "boolean", "value": "true"}` | +| Type | Example | +| --------- | -------------------------------------- | +| `string` | `{"type": "string", "value": "hello"}` | +| `integer` | `{"type": "integer", "value": "42"}` | +| `boolean` | `{"type": "boolean", "value": "true"}` | --- @@ -37,7 +40,8 @@ in one request). Each scenario below shows both. Try them and The simplest case — flip a feature flag in an environment. -**Option A** — [`POST /api/experiments/environments/{environment_key}/update-flag-v1/`](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag_v1_create) +**Option A** — +[`POST /api/experiments/environments/{environment_key}/update-flag-v1/`](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag_v1_create) ```bash curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v1/' \ @@ -50,7 +54,8 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen }' ``` -**Option B** — [`POST /api/experiments/environments/{environment_key}/update-flag-v2/`](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag_v2_create) +**Option B** — +[`POST /api/experiments/environments/{environment_key}/update-flag-v2/`](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag_v2_create) ```bash curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v2/' \ @@ -216,6 +221,147 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen --- +## Configure A/B/n experiments (multivariate flags) + +Set up multivariate flags and customise weights per segment. + +- The `multivariate_options` list is absolute: omitting an option means deleting it. +- Segment overrides can only re-weight options configured in the environment. +- Deleting a multivariate option also deletes it in every segment override. +- Segments already overriding multivariate options do not gain new variants added to the environment automatically. + +**Option A** + +```bash +# First, configure multivariate flags in the environment +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v1/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "enabled": true, + "value": {"type": "string", "value": "default"}, + "multivariate_options": [ + {"percentage_allocation": 20, "value": {"type": "string", "value": "sharp_payments"}}, + {"percentage_allocation": 10, "value": {"type": "string", "value": "gemstone_express"}} + ] + }' + +# Multivariate option `id`s can be fetched via the admin API +curl -X GET '/api/v1/projects/{project_id}/features/{feature_id}/mv-options/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' + +# You can update (add, delete, re-weight) multivariate options in the environment +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v1/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "multivariate_options": [ + {"id": 991, "percentage_allocation": 20, "value": {"type": "string", "value": "sharp_payments"}}, + {"percentage_allocation": 5, "value": {"type": "string", "value": "e-z-pay"}} + ] + }' + +# Segment overrides can re-weight multivariate options (but can't add, delete, or update values) +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v1/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "segment": {"id": 101, "priority": 1}, + "multivariate_options": [ + {"id": 991, "percentage_allocation": 0}, + {"id": 993, "percentage_allocation": 100} + ] + }' +``` + +**Option B** + +```bash +# Configure the environment default and its multivariate options +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v2/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "environment_default": { + "enabled": true, + "value": {"type": "string", "value": "default"}, + "multivariate_options": [ + {"percentage_allocation": 20, "value": {"type": "string", "value": "sharp_payments"}}, + {"percentage_allocation": 10, "value": {"type": "string", "value": "gemstone_express"}} + ] + } + }' + +# Multivariate option `id`s can be fetched via the admin API +curl -X GET '/api/v1/projects/{project_id}/features/{feature_id}/mv-options/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' + +# Re-weight the environment default and segment overrides in a single request +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v2/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "environment_default": { + "multivariate_options": [ + { + "id": 991, + "percentage_allocation": 30, + "value": {"type": "string", "value": "sharp_payments"} + }, + { + "id": 992, + "percentage_allocation": 5, + "value": {"type": "string", "value": "gemstone_express"} + } + ] + }, + "segment_overrides": [ + { + "segment_id": 101, + "priority": 1, + "multivariate_options": [ + {"id": 991, "percentage_allocation": 100}, + {"id": 992, "percentage_allocation": 0} + ] + }, + { + "segment_id": 202, + "priority": 2, + "multivariate_options": [ + {"id": 991, "percentage_allocation": 0}, + {"id": 992, "percentage_allocation": 100} + ] + } + ] + }' + +# Segment overrides can re-weight multivariate options (but can't add, delete, or update values) +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag-v2/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "segment_overrides": [ + { + "segment_id": 101, + "multivariate_options": [ + {"id": 991, "percentage_allocation": 50}, + {"id": 992, "percentage_allocation": 50} + ] + } + ] + }' +``` + +--- + ## Remove a segment override A separate endpoint for removing a segment override from a feature: @@ -236,12 +382,12 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen ## Quick reference -| Aspect | Details | -| -------------------- | ---------------------------------------------------------------------------- | -| Feature ID | `name` or `id` — use one, not both | -| Value types | `string`, `integer`, `boolean` | -| Segment priority | Optional — omit to add at lowest priority; `1` is highest | -| Feature Versioning | Works the same whether enabled or not | -| Success response | `204 No Content` | -| Limitations | No multivariate support; incompatible with change requests | -| Full API schema | [Swagger Explorer](https://api.flagsmith.com/api/v1/docs/) | +| Aspect | Details | +| ------------------ | ---------------------------------------------------------- | +| Feature ID | `name` or `id` — use one, not both | +| Value types | `string`, `integer`, `boolean` | +| Segment priority | Optional — omit to add at lowest priority; `1` is highest | +| Feature Versioning | Works the same whether enabled or not | +| Success response | `204 No Content` | +| Limitations | Incompatible with change requests | +| Full API schema | [Swagger Explorer](https://api.flagsmith.com/api/v1/docs/) | From eb775b961ba2f52ebd597ce7eacc7a99191ff915 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 7 Jul 2026 22:58:16 -0300 Subject: [PATCH 3/9] improve types --- api/api/urls/experiments.py | 12 +-- api/experimentation/services.py | 10 +- api/features/feature_states/serializers.py | 98 +++++++++++-------- api/features/feature_states/types.py | 38 +++++++ api/features/feature_states/views.py | 16 +-- api/features/versioning/dataclasses.py | 4 +- api/features/versioning/versioning_service.py | 26 ++--- .../identities/test_integration_identities.py | 18 ++-- api/tests/integration/helpers.py | 2 +- .../feature_states/test_serializers.py | 30 +++--- .../test_unit_feature_states_views.py | 88 ++++++++--------- ...test_unit_versioning_versioning_service.py | 40 ++++---- 12 files changed, 216 insertions(+), 166 deletions(-) create mode 100644 api/features/feature_states/types.py diff --git a/api/api/urls/experiments.py b/api/api/urls/experiments.py index 984709354663..92efd49cc9c9 100644 --- a/api/api/urls/experiments.py +++ b/api/api/urls/experiments.py @@ -9,8 +9,8 @@ from features.feature_states.views import ( delete_segment_override, - update_flag_v1, - update_flag_v2, + update_flag_option_a, + update_flag_option_b, ) app_name = "experiments" @@ -18,13 +18,13 @@ urlpatterns = [ path( "environments//update-flag-v1/", - update_flag_v1, - name="update-flag-v1", + update_flag_option_a, + name="update-flag-option-a", ), path( "environments//update-flag-v2/", - update_flag_v2, - name="update-flag-v2", + update_flag_option_b, + name="update-flag-option-b", ), path( "environments//delete-segment-override/", diff --git a/api/experimentation/services.py b/api/experimentation/services.py index abddebe6dc86..59c7cc5a0d46 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -57,7 +57,7 @@ ) from features.models import FeatureState from features.value_types import BOOLEAN, INTEGER, STRING -from features.versioning.dataclasses import FlagChangeSet +from features.versioning.dataclasses import FlagChangeSetOptionA from features.versioning.versioning_service import ( update_flag, update_multivariate_values, @@ -594,7 +594,7 @@ def _get_live_rollout_override(experiment: Experiment) -> FeatureState | None: def _update_live_feature_state( - feature_state: FeatureState, change_set: FlagChangeSet + feature_state: FeatureState, change_set: FlagChangeSetOptionA ) -> None: feature_state.enabled = change_set.enabled feature_state.save() @@ -605,7 +605,7 @@ def _update_live_feature_state( update_multivariate_values(feature_state, change_set.multivariate_values) -def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet) -> None: +def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSetOptionA) -> None: """Write the rollout-segment override, keeping variant assignment stable. Under v2 versioning, ``update_flag`` clones the override into a fresh feature @@ -638,7 +638,7 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: segment = _sync_rollout_segment(experiment, spec.rollout_percentage) _update_rollout_in_place( experiment, - FlagChangeSet( + FlagChangeSetOptionA( author=spec.author, enabled=spec.enabled, feature_state_value=spec.feature_state_value, @@ -699,7 +699,7 @@ def enable_experiment_rollout(experiment: Experiment, author: AuthorData) -> Non value = rollout["feature_state_value"] _update_rollout_in_place( experiment, - FlagChangeSet( + FlagChangeSetOptionA( author=author, enabled=True, feature_state_value=value["value"], diff --git a/api/features/feature_states/serializers.py b/api/features/feature_states/serializers.py index df20853d176a..764c191ef3d7 100644 --- a/api/features/feature_states/serializers.py +++ b/api/features/feature_states/serializers.py @@ -1,25 +1,36 @@ -from typing import Any +from typing import Any, TypeVar from rest_framework import serializers from core.dataclasses import AuthorData from environments.models import Environment +from features.feature_states.types import ( + EnvironmentDefaultPayload, + FeatureIdentifierPayload, + FeatureValuePayload, + MultivariateValuePayload, + SegmentIdentifierPayload, + SegmentOverridePayload, + SegmentPayload, +) from features.models import Feature, FeatureState from features.versioning.dataclasses import ( - FlagChangeSet, - FlagChangeSetV2, + FlagChangeSetOptionA, + FlagChangeSetOptionB, MultivariateValueChangeSet, SegmentOverrideChangeSet, ) from features.versioning.versioning_service import ( delete_segment_override, update_flag, - update_flag_v2, + update_flag_option_b, ) from segments.models import Segment +_InstanceT = TypeVar("_InstanceT") + -class BaseFeatureUpdateSerializer(serializers.Serializer): # type: ignore[type-arg] +class BaseFeatureUpdateSerializer(serializers.Serializer[_InstanceT]): @property def environment(self) -> Environment: environment: Environment | None = self.context.get("environment") @@ -48,36 +59,36 @@ def validate_segment_id(self, segment_id: int) -> None: ) -class FeatureIdentifierSerializer(serializers.Serializer): # type: ignore[type-arg] +class FeatureIdentifierSerializer(serializers.Serializer[FeatureIdentifierPayload]): name = serializers.CharField(required=False, allow_blank=False) id = serializers.IntegerField(required=False) - def validate(self, data: dict) -> dict: # type: ignore[type-arg] - has_name = "name" in data - has_id = "id" in data + def validate(self, attrs: FeatureIdentifierPayload) -> FeatureIdentifierPayload: + has_name = "name" in attrs + has_id = "id" in attrs if not has_name and not has_id: raise serializers.ValidationError( "Either 'name' or 'id' is required for feature identification" ) if has_name and has_id: raise serializers.ValidationError("Provide either 'name' or 'id', not both") - return data + return attrs -class FeatureUpdateSegmentDataSerializer(serializers.Serializer): # type: ignore[type-arg] +class FeatureUpdateSegmentDataSerializer(serializers.Serializer[SegmentPayload]): id = serializers.IntegerField(required=True) priority = serializers.IntegerField(required=False, allow_null=True) -class FeatureValueSerializer(serializers.Serializer): # type: ignore[type-arg] +class FeatureValueSerializer(serializers.Serializer[FeatureValuePayload]): type = serializers.ChoiceField( choices=["integer", "string", "boolean"], required=True ) value = serializers.CharField(required=True, allow_blank=True) - def validate(self, data: dict) -> dict: # type: ignore[type-arg] - value_type = data["type"] - string_val = data["value"] + def validate(self, attrs: FeatureValuePayload) -> FeatureValuePayload: + value_type = attrs["type"] + string_val = attrs["value"] if value_type == "integer": try: @@ -92,27 +103,27 @@ def validate(self, data: dict) -> dict: # type: ignore[type-arg] f"'{string_val}' is not a valid boolean (use 'true' or 'false')" ) - return data + return attrs -class UpdateFlagSerializer(BaseFeatureUpdateSerializer): +class UpdateFlagOptionASerializer(BaseFeatureUpdateSerializer[FeatureState]): feature = FeatureIdentifierSerializer(required=True) segment = FeatureUpdateSegmentDataSerializer(required=False) enabled = serializers.BooleanField(required=True) value = FeatureValueSerializer(required=True) - def validate_segment(self, value: dict) -> dict: # type: ignore[type-arg] + def validate_segment(self, value: SegmentPayload) -> SegmentPayload: if value and "id" in value: self.validate_segment_id(value["id"]) return value @property - def flag_change_set(self) -> FlagChangeSet: + def flag_change_set(self) -> FlagChangeSetOptionA: validated_data = self.validated_data value_data = validated_data["value"] segment_data = validated_data.get("segment") - return FlagChangeSet( + return FlagChangeSetOptionA( author=AuthorData.from_request(self.context["request"]), enabled=validated_data["enabled"], feature_state_value=value_data["value"], @@ -126,12 +137,12 @@ def save(self, **kwargs: object) -> FeatureState: return update_flag(self.environment, feature, self.flag_change_set) -class EnvironmentDefaultSerializer(serializers.Serializer): # type: ignore[type-arg] +class EnvironmentDefaultSerializer(serializers.Serializer[EnvironmentDefaultPayload]): enabled = serializers.BooleanField(required=True) value = FeatureValueSerializer(required=True) -class MultivariateValueSerializer(serializers.Serializer): # type: ignore[type-arg] +class MultivariateValueSerializer(serializers.Serializer[MultivariateValuePayload]): multivariate_feature_option = serializers.IntegerField(required=True) percentage_allocation = serializers.FloatField( required=True, min_value=0, max_value=100 @@ -139,7 +150,7 @@ class MultivariateValueSerializer(serializers.Serializer): # type: ignore[type- def validate_multivariate_state_values( - feature: Feature, multivariate_values: list[dict[str, Any]] + feature: Feature, multivariate_values: list[MultivariateValuePayload] ) -> None: if not multivariate_values: return @@ -153,7 +164,7 @@ def validate_multivariate_state_values( ) -class SegmentOverrideSerializer(serializers.Serializer): # type: ignore[type-arg] +class SegmentOverrideSerializer(serializers.Serializer[SegmentOverridePayload]): segment_id = serializers.IntegerField(required=True) priority = serializers.IntegerField(required=False, allow_null=True) enabled = serializers.BooleanField(required=True) @@ -163,15 +174,14 @@ class SegmentOverrideSerializer(serializers.Serializer): # type: ignore[type-ar ) -class UpdateFlagV2Serializer(BaseFeatureUpdateSerializer): +class UpdateFlagOptionBSerializer(BaseFeatureUpdateSerializer[FlagChangeSetOptionB]): feature = FeatureIdentifierSerializer(required=True) environment_default = EnvironmentDefaultSerializer(required=True) segment_overrides = SegmentOverrideSerializer(many=True, required=False) def validate_segment_overrides( - self, - value: list[dict], # type: ignore[type-arg] - ) -> list[dict]: # type: ignore[type-arg] + self, value: list[SegmentOverridePayload] + ) -> list[SegmentOverridePayload]: if not value: return value @@ -187,22 +197,20 @@ def validate_segment_overrides( return value - def validate(self, data: dict) -> dict: # type: ignore[type-arg] - overrides = data.get("segment_overrides", []) + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + overrides: list[SegmentOverridePayload] = attrs.get("segment_overrides", []) if any(o.get("multivariate_feature_state_values") for o in overrides): feature = Feature.objects.filter( - project_id=self.environment.project_id, **data["feature"] + project_id=self.environment.project_id, **attrs["feature"] ).first() if feature is not None: for override in overrides: - validate_multivariate_state_values( - feature, - override.get("multivariate_feature_state_values", []), - ) - return data + mv_overrides = override.get("multivariate_feature_state_values", []) + validate_multivariate_state_values(feature, mv_overrides) + return attrs @property - def change_set_v2(self) -> FlagChangeSetV2: + def change_set(self) -> FlagChangeSetOptionB: validated_data = self.validated_data env_default = validated_data["environment_default"] @@ -235,7 +243,7 @@ def change_set_v2(self) -> FlagChangeSetV2: ) segment_overrides.append(segment_override) - return FlagChangeSetV2( + return FlagChangeSetOptionB( author=AuthorData.from_request(self.context["request"]), environment_default_enabled=env_default["enabled"], environment_default_value=env_value_data["value"], @@ -243,20 +251,24 @@ def change_set_v2(self) -> FlagChangeSetV2: segment_overrides=segment_overrides, ) - def save(self, **kwargs: object) -> None: + def save(self, **kwargs: object) -> FlagChangeSetOptionB: feature = self.get_feature() - update_flag_v2(self.environment, feature, self.change_set_v2) + change_set = self.change_set + update_flag_option_b(self.environment, feature, change_set) + return change_set -class SegmentIdentifierSerializer(serializers.Serializer): # type: ignore[type-arg] +class SegmentIdentifierSerializer(serializers.Serializer[SegmentIdentifierPayload]): id = serializers.IntegerField(required=True) -class DeleteSegmentOverrideSerializer(BaseFeatureUpdateSerializer): +class DeleteSegmentOverrideSerializer(BaseFeatureUpdateSerializer[None]): feature = FeatureIdentifierSerializer(required=True) segment = SegmentIdentifierSerializer(required=True) - def validate_segment(self, value: dict) -> dict: # type: ignore[type-arg] + def validate_segment( + self, value: SegmentIdentifierPayload + ) -> SegmentIdentifierPayload: if value and value.get("id"): self.validate_segment_id(value["id"]) return value diff --git a/api/features/feature_states/types.py b/api/features/feature_states/types.py new file mode 100644 index 000000000000..9ea06de5c86a --- /dev/null +++ b/api/features/feature_states/types.py @@ -0,0 +1,38 @@ +from typing import Literal, NotRequired, TypedDict + + +class FeatureIdentifierPayload(TypedDict): + name: NotRequired[str] + id: NotRequired[int] + + +class FeatureValuePayload(TypedDict): + type: Literal["integer", "string", "boolean"] + value: str + + +class SegmentPayload(TypedDict): + id: int + priority: NotRequired[int | None] + + +class SegmentIdentifierPayload(TypedDict): + id: int + + +class MultivariateValuePayload(TypedDict): + multivariate_feature_option: int + percentage_allocation: float + + +class EnvironmentDefaultPayload(TypedDict): + enabled: bool + value: FeatureValuePayload + + +class SegmentOverridePayload(TypedDict): + segment_id: int + priority: NotRequired[int | None] + enabled: bool + value: FeatureValuePayload + multivariate_feature_state_values: NotRequired[list[MultivariateValuePayload]] diff --git a/api/features/feature_states/views.py b/api/features/feature_states/views.py index e3d5208005eb..e9338d7c36ff 100644 --- a/api/features/feature_states/views.py +++ b/api/features/feature_states/views.py @@ -11,8 +11,8 @@ from .permissions import EnvironmentUpdateFeatureStatePermission from .serializers import ( DeleteSegmentOverrideSerializer, - UpdateFlagSerializer, - UpdateFlagV2Serializer, + UpdateFlagOptionASerializer, + UpdateFlagOptionBSerializer, ) @@ -58,17 +58,17 @@ def _check_workflow_not_enabled(environment: Environment) -> None: required=True, ) ], - request=UpdateFlagSerializer, + request=UpdateFlagOptionASerializer, responses={204: None}, tags=["experimental"], ) @api_view(http_method_names=["POST"]) @permission_classes([IsAuthenticated, EnvironmentUpdateFeatureStatePermission]) -def update_flag_v1(request: Request, environment_key: str) -> Response: +def update_flag_option_a(request: Request, environment_key: str) -> Response: environment = Environment.objects.get(api_key=environment_key) _check_workflow_not_enabled(environment) - serializer = UpdateFlagSerializer( + serializer = UpdateFlagOptionASerializer( data=request.data, context={"request": request, "environment": environment}, ) @@ -120,17 +120,17 @@ def update_flag_v1(request: Request, environment_key: str) -> Response: required=True, ) ], - request=UpdateFlagV2Serializer, + request=UpdateFlagOptionBSerializer, responses={204: None}, tags=["experimental"], ) @api_view(http_method_names=["POST"]) @permission_classes([IsAuthenticated, EnvironmentUpdateFeatureStatePermission]) -def update_flag_v2(request: Request, environment_key: str) -> Response: +def update_flag_option_b(request: Request, environment_key: str) -> Response: environment = Environment.objects.get(api_key=environment_key) _check_workflow_not_enabled(environment) - serializer = UpdateFlagV2Serializer( + serializer = UpdateFlagOptionBSerializer( data=request.data, context={"request": request, "environment": environment}, ) diff --git a/api/features/versioning/dataclasses.py b/api/features/versioning/dataclasses.py index 65fd78dacf70..0157a445255d 100644 --- a/api/features/versioning/dataclasses.py +++ b/api/features/versioning/dataclasses.py @@ -21,7 +21,7 @@ def is_environment_default(self) -> bool: @dataclass -class FlagChangeSet: +class FlagChangeSetOptionA: author: AuthorData enabled: bool feature_state_value: str @@ -49,7 +49,7 @@ class SegmentOverrideChangeSet: @dataclass -class FlagChangeSetV2: +class FlagChangeSetOptionB: author: AuthorData environment_default_enabled: bool environment_default_value: str diff --git a/api/features/versioning/versioning_service.py b/api/features/versioning/versioning_service.py index 1428faee7d5e..a9d141176771 100644 --- a/api/features/versioning/versioning_service.py +++ b/api/features/versioning/versioning_service.py @@ -11,8 +11,8 @@ from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue from features.multivariate.models import MultivariateFeatureStateValue from features.versioning.dataclasses import ( - FlagChangeSet, - FlagChangeSetV2, + FlagChangeSetOptionA, + FlagChangeSetOptionB, MultivariateValueChangeSet, ) from features.versioning.exceptions import DirectFeatureStateWriteNotAllowedError @@ -132,7 +132,7 @@ def get_current_live_environment_feature_version( def update_flag( - environment: Environment, feature: Feature, change_set: FlagChangeSet + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionA ) -> FeatureState: if environment.use_v2_feature_versioning: return _update_flag_for_versioning_v2(environment, feature, change_set) @@ -141,7 +141,7 @@ def update_flag( def _update_flag_for_versioning_v2( - environment: Environment, feature: Feature, change_set: FlagChangeSet + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionA ) -> FeatureState: from features.models import FeatureSegment, FeatureState @@ -202,7 +202,7 @@ def _update_flag_for_versioning_v2( def _update_flag_for_versioning_v1( - environment: Environment, feature: Feature, change_set: FlagChangeSet + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionA ) -> FeatureState: from features.models import FeatureSegment, FeatureState @@ -330,17 +330,17 @@ def _update_segment_priority(feature_state: FeatureState, priority: int) -> None feature_segment.to(priority) -def update_flag_v2( - environment: Environment, feature: Feature, change_set: FlagChangeSetV2 +def update_flag_option_b( + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionB ) -> None: if environment.use_v2_feature_versioning: - _update_flag_v2_for_versioning_v2(environment, feature, change_set) + _update_flag_option_b_for_versioning_v2(environment, feature, change_set) else: - _update_flag_v2_for_versioning_v1(environment, feature, change_set) + _update_flag_option_b_for_versioning_v1(environment, feature, change_set) -def _update_flag_v2_for_versioning_v2( - environment: Environment, feature: Feature, change_set: FlagChangeSetV2 +def _update_flag_option_b_for_versioning_v2( + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionB ) -> None: new_version = EnvironmentFeatureVersion.objects.create( environment=environment, @@ -401,8 +401,8 @@ def _update_flag_v2_for_versioning_v2( ) -def _update_flag_v2_for_versioning_v1( - environment: Environment, feature: Feature, change_set: FlagChangeSetV2 +def _update_flag_option_b_for_versioning_v1( + environment: Environment, feature: Feature, change_set: FlagChangeSetOptionB ) -> None: env_default_states = get_environment_flags_dict( environment=environment, diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index 58e865ba3dbb..5be2e646b900 100644 --- a/api/tests/integration/environments/identities/test_integration_identities.py +++ b/api/tests/integration/environments/identities/test_integration_identities.py @@ -73,14 +73,14 @@ def test_get_feature_states_for_identity__mv_percentage_allocation__returns_corr create_mv_option_with_api( admin_client, project, - multivariate_feature_id, # type: ignore[arg-type] + multivariate_feature_id, variant_1_percentage_allocation, variant_1_value, ) variant_2_mvfo_id = create_mv_option_with_api( admin_client, project, - multivariate_feature_id, # type: ignore[arg-type] + multivariate_feature_id, variant_2_percentage_allocation, variant_2_value, ) @@ -193,7 +193,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ create_mv_option_with_api( admin_client, project, - multivariate_feature_id, # type: ignore[arg-type] + multivariate_feature_id, variant_1_percentage_allocation, variant_1_value, key="variant-1", @@ -201,7 +201,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ create_mv_option_with_api( admin_client, project, - multivariate_feature_id, # type: ignore[arg-type] + multivariate_feature_id, variant_2_percentage_allocation, variant_2_value, key="variant-2", @@ -242,7 +242,7 @@ def test_get_flags__multivariate_feature__response_excludes_variant( # type: ig create_mv_option_with_api( admin_client, project, - multivariate_feature_id, # type: ignore[arg-type] + multivariate_feature_id, 100, variant_1_value, key="variant-1", @@ -279,14 +279,14 @@ def test_get_feature_states_for_identity__multiple_mv_features__single_mv_query( create_mv_option_with_api( admin_client, project, - feature_id, # type: ignore[arg-type] + feature_id, variant_1_percentage_allocation, variant_1_value, ) create_mv_option_with_api( admin_client, project, - feature_id, # type: ignore[arg-type] + feature_id, variant_2_percentage_allocation, variant_2_value, ) @@ -314,14 +314,14 @@ def test_get_feature_states_for_identity__multiple_mv_features__single_mv_query( create_mv_option_with_api( admin_client, project, - feature_id, # type: ignore[arg-type] + feature_id, variant_1_percentage_allocation, variant_1_value, ) create_mv_option_with_api( admin_client, project, - feature_id, # type: ignore[arg-type] + feature_id, variant_2_percentage_allocation, variant_2_value, ) diff --git a/api/tests/integration/helpers.py b/api/tests/integration/helpers.py index cd98edd267bc..0145d252c513 100644 --- a/api/tests/integration/helpers.py +++ b/api/tests/integration/helpers.py @@ -45,7 +45,7 @@ def create_feature_with_api( def create_mv_option_with_api( client: APIClient, project_id: int, - feature_id: str, + feature_id: int, default_percentage_allocation: float, value: str, key: str | None = None, diff --git a/api/tests/unit/features/feature_states/test_serializers.py b/api/tests/unit/features/feature_states/test_serializers.py index 42eedd413584..3ab5768b82bc 100644 --- a/api/tests/unit/features/feature_states/test_serializers.py +++ b/api/tests/unit/features/feature_states/test_serializers.py @@ -6,8 +6,8 @@ from environments.models import Environment from features.feature_states.serializers import ( FeatureValueSerializer, - UpdateFlagSerializer, - UpdateFlagV2Serializer, + UpdateFlagOptionASerializer, + UpdateFlagOptionBSerializer, validate_multivariate_state_values, ) from features.models import Feature @@ -19,7 +19,7 @@ def test_get_feature__no_environment_in_context__raises_validation_error( feature: Feature, ) -> None: # Given - serializer = UpdateFlagSerializer( + serializer = UpdateFlagOptionASerializer( data={ "feature": {"name": feature.name}, "enabled": True, @@ -39,7 +39,7 @@ def test_get_feature__no_environment_in_context__raises_validation_error( def test_validate_segment_overrides__empty_list__returns_empty_list() -> None: # Given - serializer = UpdateFlagV2Serializer() + serializer = UpdateFlagOptionBSerializer() # When result = serializer.validate_segment_overrides([]) @@ -78,7 +78,7 @@ def test_feature_value_serializer__invalid_boolean__returns_not_valid() -> None: "serializer_class,data_factory", [ ( - UpdateFlagSerializer, + UpdateFlagOptionASerializer, lambda feature, segment_id: { "feature": {"name": feature.name}, "segment": {"id": segment_id}, @@ -87,7 +87,7 @@ def test_feature_value_serializer__invalid_boolean__returns_not_valid() -> None: }, ), ( - UpdateFlagV2Serializer, + UpdateFlagOptionBSerializer, lambda feature, segment_id: { "feature": {"name": feature.name}, "environment_default": { @@ -129,7 +129,7 @@ def test_update_flag_serializer__nonexistent_segment__returns_invalid( "serializer_class,data_factory", [ ( - UpdateFlagSerializer, + UpdateFlagOptionASerializer, lambda feature, segment_id: { "feature": {"name": feature.name}, "segment": {"id": segment_id}, @@ -138,7 +138,7 @@ def test_update_flag_serializer__nonexistent_segment__returns_invalid( }, ), ( - UpdateFlagV2Serializer, + UpdateFlagOptionBSerializer, lambda feature, segment_id: { "feature": {"name": feature.name}, "environment_default": { @@ -182,13 +182,13 @@ def test_update_flag_serializer__cross_project_segment__returns_invalid( assert "not found in project" in str(serializer.errors) -def test_update_flag_v2_serializer__mv_option_not_on_feature__returns_invalid( +def test_update_flag_option_b_serializer__mv_option_not_on_feature__returns_invalid( multivariate_feature: Feature, environment: Environment, segment: Segment, ) -> None: # Given - serializer = UpdateFlagV2Serializer( + serializer = UpdateFlagOptionBSerializer( data={ "feature": {"name": multivariate_feature.name}, "environment_default": { @@ -220,7 +220,7 @@ def test_update_flag_v2_serializer__mv_option_not_on_feature__returns_invalid( assert "do not belong to the feature" in str(serializer.errors) -def test_update_flag_v2_serializer__duplicate_mv_option__returns_invalid( +def test_update_flag_option_b_serializer__duplicate_mv_option__returns_invalid( multivariate_feature: Feature, multivariate_options: list[typing.Any], environment: Environment, @@ -228,7 +228,7 @@ def test_update_flag_v2_serializer__duplicate_mv_option__returns_invalid( ) -> None: # Given the same multivariate option is passed twice option = multivariate_options[0] - serializer = UpdateFlagV2Serializer( + serializer = UpdateFlagOptionBSerializer( data={ "feature": {"name": multivariate_feature.name}, "environment_default": { @@ -274,7 +274,7 @@ def test_validate_multivariate_state_values__empty_list__is_noop( validate_multivariate_state_values(feature, multivariate_values) -def test_update_flag_v2_serializer__valid_mv_option__change_set_carries_mv( +def test_update_flag_option_b_serializer__valid_mv_option__change_set_carries_mv( multivariate_feature: Feature, multivariate_options: list, # type: ignore[type-arg] environment: Environment, @@ -286,7 +286,7 @@ def test_update_flag_v2_serializer__valid_mv_option__change_set_carries_mv( option = multivariate_options[0] request = rf.post("/") request.user = admin_user - serializer = UpdateFlagV2Serializer( + serializer = UpdateFlagOptionBSerializer( data={ "feature": {"name": multivariate_feature.name}, "environment_default": { @@ -312,7 +312,7 @@ def test_update_flag_v2_serializer__valid_mv_option__change_set_carries_mv( # When is_valid = serializer.is_valid() - change_set = serializer.change_set_v2 + change_set = serializer.change_set # Then assert is_valid is True diff --git a/api/tests/unit/features/feature_states/test_unit_feature_states_views.py b/api/tests/unit/features/feature_states/test_unit_feature_states_views.py index d11244a590c4..b607077db255 100644 --- a/api/tests/unit/features/feature_states/test_unit_feature_states_views.py +++ b/api/tests/unit/features/feature_states/test_unit_feature_states_views.py @@ -31,7 +31,7 @@ "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__by_feature_name__updates_feature_state( +def test_update_flag_option_a__by_feature_name__updates_feature_state( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -43,7 +43,7 @@ def test_update_flag_v1__by_feature_name__updates_feature_state( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -73,7 +73,7 @@ def test_update_flag_v1__by_feature_name__updates_feature_state( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__by_feature_id__updates_feature_state( +def test_update_flag_option_a__by_feature_id__updates_feature_state( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -82,7 +82,7 @@ def test_update_flag_v1__by_feature_id__updates_feature_state( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -112,7 +112,7 @@ def test_update_flag_v1__by_feature_id__updates_feature_state( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__both_name_and_id_provided__returns_400( +def test_update_flag_option_a__both_name_and_id_provided__returns_400( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -121,7 +121,7 @@ def test_update_flag_v1__both_name_and_id_provided__returns_400( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -147,7 +147,7 @@ def test_update_flag_v1__both_name_and_id_provided__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__both_name_and_id_for_different_features__returns_400( +def test_update_flag_option_a__both_name_and_id_for_different_features__returns_400( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -163,7 +163,7 @@ def test_update_flag_v1__both_name_and_id_for_different_features__returns_400( ) url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -189,7 +189,7 @@ def test_update_flag_v1__both_name_and_id_for_different_features__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__neither_name_nor_id_provided__returns_400( +def test_update_flag_option_a__neither_name_nor_id_provided__returns_400( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -198,7 +198,7 @@ def test_update_flag_v1__neither_name_nor_id_provided__returns_400( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -224,7 +224,7 @@ def test_update_flag_v1__neither_name_nor_id_provided__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__feature_not_found_by_name__returns_400( +def test_update_flag_option_a__feature_not_found_by_name__returns_400( staff_client: APIClient, environment_: Environment, with_environment_permissions: WithEnvironmentPermissionsCallable, @@ -232,7 +232,7 @@ def test_update_flag_v1__feature_not_found_by_name__returns_400( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -256,7 +256,7 @@ def test_update_flag_v1__feature_not_found_by_name__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__feature_not_found_by_id__returns_400( +def test_update_flag_option_a__feature_not_found_by_id__returns_400( staff_client: APIClient, environment_: Environment, with_environment_permissions: WithEnvironmentPermissionsCallable, @@ -264,7 +264,7 @@ def test_update_flag_v1__feature_not_found_by_id__returns_400( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -288,7 +288,7 @@ def test_update_flag_v1__feature_not_found_by_id__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__segment_override_by_name__creates_override( +def test_update_flag_option_a__segment_override_by_name__creates_override( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -303,7 +303,7 @@ def test_update_flag_v1__segment_override_by_name__creates_override( ) url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -339,7 +339,7 @@ def test_update_flag_v1__segment_override_by_name__creates_override( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__segment_override_no_existing_feature_segment__creates_feature_segment( +def test_update_flag_option_a__segment_override_no_existing_feature_segment__creates_feature_segment( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -361,7 +361,7 @@ def test_update_flag_v1__segment_override_no_existing_feature_segment__creates_f ).exists() url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -395,7 +395,7 @@ def test_update_flag_v1__segment_override_no_existing_feature_segment__creates_f assert segment_override.feature_segment.priority == 10 -def test_update_flag_v1__new_segment_override_at_priority_zero__reorders_existing_priorities( +def test_update_flag_option_a__new_segment_override_at_priority_zero__reorders_existing_priorities( staff_client: APIClient, feature: Feature, environment: Environment, @@ -423,7 +423,7 @@ def test_update_flag_v1__new_segment_override_at_priority_zero__reorders_existin ) url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment.api_key}, ) @@ -459,7 +459,7 @@ def test_update_flag_v1__new_segment_override_at_priority_zero__reorders_existin "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v2__new_segment_overrides__creates_overrides( +def test_update_flag_option_b__new_segment_overrides__creates_overrides( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -486,7 +486,7 @@ def test_update_flag_v2__new_segment_overrides__creates_overrides( ).exists() url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment_.api_key}, ) @@ -547,7 +547,7 @@ def test_update_flag_v2__new_segment_overrides__creates_overrides( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v2__environment_default_only__updates_default_state( +def test_update_flag_option_b__environment_default_only__updates_default_state( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -556,7 +556,7 @@ def test_update_flag_v2__environment_default_only__updates_default_state( # Given with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg] url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment_.api_key}, ) @@ -588,7 +588,7 @@ def test_update_flag_v2__environment_default_only__updates_default_state( assert env_default.get_feature_state_value() == 100 -def test_update_flag_v2__duplicate_segment_ids__returns_400( +def test_update_flag_option_b__duplicate_segment_ids__returns_400( staff_client: APIClient, feature: Feature, environment: Environment, @@ -601,7 +601,7 @@ def test_update_flag_v2__duplicate_segment_ids__returns_400( segment1 = Segment.objects.create(name="segment1", project=project) url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment.api_key}, ) @@ -640,7 +640,7 @@ def test_update_flag_v2__duplicate_segment_ids__returns_400( "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v1__existing_segment_override_with_new_priority__updates_priority( +def test_update_flag_option_a__existing_segment_override_with_new_priority__updates_priority( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -668,7 +668,7 @@ def test_update_flag_v1__existing_segment_override_with_new_priority__updates_pr ) url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment_.api_key}, ) @@ -702,7 +702,7 @@ def test_update_flag_v1__existing_segment_override_with_new_priority__updates_pr "environment_", (lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")), ) -def test_update_flag_v2__existing_segment_override_with_new_priority__updates_priority( +def test_update_flag_option_b__existing_segment_override_with_new_priority__updates_priority( staff_client: APIClient, feature: Feature, environment_: Environment, @@ -731,7 +731,7 @@ def test_update_flag_v2__existing_segment_override_with_new_priority__updates_pr # When - Update the existing segment override using V2 endpoint v2_url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment_.api_key}, ) update_data = { @@ -768,7 +768,7 @@ def test_update_flag_v2__existing_segment_override_with_new_priority__updates_pr assert feature_segment.priority == 2 -def test_update_flag_v2__new_segment_override_at_priority_zero__reorders_existing_priorities( +def test_update_flag_option_b__new_segment_override_at_priority_zero__reorders_existing_priorities( staff_client: APIClient, feature: Feature, environment: Environment, @@ -796,7 +796,7 @@ def test_update_flag_v2__new_segment_override_at_priority_zero__reorders_existin ) url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment.api_key}, ) @@ -834,7 +834,7 @@ def test_update_flag_v2__new_segment_override_at_priority_zero__reorders_existin assert feature_segment1.priority == 1 -def test_update_flag_v1__workflow_enabled__returns_403( +def test_update_flag_option_a__workflow_enabled__returns_403( staff_client: APIClient, feature: Feature, environment: Environment, @@ -846,7 +846,7 @@ def test_update_flag_v1__workflow_enabled__returns_403( environment.save() url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment.api_key}, ) @@ -866,7 +866,7 @@ def test_update_flag_v1__workflow_enabled__returns_403( assert "change requests are enabled" in str(response.json()) -def test_update_flag_v2__workflow_enabled__returns_403( +def test_update_flag_option_b__workflow_enabled__returns_403( staff_client: APIClient, feature: Feature, environment: Environment, @@ -878,7 +878,7 @@ def test_update_flag_v2__workflow_enabled__returns_403( environment.save() url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment.api_key}, ) @@ -900,7 +900,7 @@ def test_update_flag_v2__workflow_enabled__returns_403( assert "change requests are enabled" in str(response.json()) -def test_update_flag_v2__existing_segment_override_with_v2_versioning__updates_override( +def test_update_flag_option_b__existing_segment_override_with_v2_versioning__updates_override( staff_client: APIClient, feature: Feature, environment_v2_versioning: Environment, @@ -931,7 +931,7 @@ def test_update_flag_v2__existing_segment_override_with_v2_versioning__updates_o ) url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment_v2_versioning.api_key}, ) @@ -974,14 +974,14 @@ def test_update_flag_v2__existing_segment_override_with_v2_versioning__updates_o assert segment_override.feature_segment.priority == 1 -def test_update_flag_v1__no_permission__returns_403( +def test_update_flag_option_a__no_permission__returns_403( staff_client: APIClient, feature: Feature, environment: Environment, ) -> None: # Given - no permissions granted url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": environment.api_key}, ) @@ -1000,14 +1000,14 @@ def test_update_flag_v1__no_permission__returns_403( assert response.status_code == status.HTTP_403_FORBIDDEN -def test_update_flag_v2__no_permission__returns_403( +def test_update_flag_option_b__no_permission__returns_403( staff_client: APIClient, feature: Feature, environment: Environment, ) -> None: # Given - no permissions granted url = reverse( - "api-experiments:update-flag-v2", + "api-experiments:update-flag-option-b", kwargs={"environment_key": environment.api_key}, ) @@ -1028,12 +1028,12 @@ def test_update_flag_v2__no_permission__returns_403( assert response.status_code == status.HTTP_403_FORBIDDEN -def test_update_flag_v1__nonexistent_environment__returns_403( +def test_update_flag_option_a__nonexistent_environment__returns_403( staff_client: APIClient, ) -> None: # Given url = reverse( - "api-experiments:update-flag-v1", + "api-experiments:update-flag-option-a", kwargs={"environment_key": "nonexistent_key"}, ) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index 67e9d2e5dbc3..2c9fd6936f1d 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -16,8 +16,8 @@ MultivariateFeatureStateValue, ) from features.versioning.dataclasses import ( - FlagChangeSet, - FlagChangeSetV2, + FlagChangeSetOptionA, + FlagChangeSetOptionB, MultivariateValueChangeSet, SegmentOverrideChangeSet, ) @@ -28,7 +28,7 @@ get_environment_flags_queryset, get_updated_feature_states_for_version, update_flag, - update_flag_v2, + update_flag_option_b, ) from projects.models import Project from segments.models import Segment @@ -639,8 +639,8 @@ def _mv_change_set( segment: Segment, *, multivariate_values: list[MultivariateValueChangeSet] | None, -) -> FlagChangeSetV2: - return FlagChangeSetV2( +) -> FlagChangeSetOptionB: + return FlagChangeSetOptionB( author=author, environment_default_enabled=True, environment_default_value="control", @@ -686,7 +686,7 @@ def _override_allocations(override: FeatureState) -> dict[int, float]: "environment_fixture_name", ["environment", "environment_v2_versioning"], ) -def test_update_flag_v2__new_segment_override_with_mv__creates_mv_values( +def test_update_flag_option_b__new_segment_override_with_mv__creates_mv_values( environment_fixture_name: str, multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], @@ -707,7 +707,7 @@ def test_update_flag_v2__new_segment_override_with_mv__creates_mv_values( ) # When - update_flag_v2(environment, multivariate_feature, change_set) + update_flag_option_b(environment, multivariate_feature, change_set) # Then override = _get_live_override(environment, multivariate_feature, segment) @@ -718,7 +718,7 @@ def test_update_flag_v2__new_segment_override_with_mv__creates_mv_values( "environment_fixture_name", ["environment", "environment_v2_versioning"], ) -def test_update_flag_v2__existing_override_mv_changed__updates_allocations( +def test_update_flag_option_b__existing_override_mv_changed__updates_allocations( environment_fixture_name: str, multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], @@ -730,7 +730,7 @@ def test_update_flag_v2__existing_override_mv_changed__updates_allocations( environment: Environment = request.getfixturevalue(environment_fixture_name) author = AuthorData(user=admin_user) option_a, option_b, _ = multivariate_options - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -744,7 +744,7 @@ def test_update_flag_v2__existing_override_mv_changed__updates_allocations( ) # When - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -766,7 +766,7 @@ def test_update_flag_v2__existing_override_mv_changed__updates_allocations( "environment_fixture_name", ["environment", "environment_v2_versioning"], ) -def test_update_flag_v2__option_not_passed__is_retained( +def test_update_flag_option_b__option_not_passed__is_retained( environment_fixture_name: str, multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], @@ -778,7 +778,7 @@ def test_update_flag_v2__option_not_passed__is_retained( environment: Environment = request.getfixturevalue(environment_fixture_name) author = AuthorData(user=admin_user) option_a, option_b, _ = multivariate_options - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -792,7 +792,7 @@ def test_update_flag_v2__option_not_passed__is_retained( ) # When only option_a is passed - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -811,7 +811,7 @@ def test_update_flag_v2__option_not_passed__is_retained( "environment_fixture_name", ["environment", "environment_v2_versioning"], ) -def test_update_flag_v2__no_mv_values__leaves_existing_mv_untouched( +def test_update_flag_option_b__no_mv_values__leaves_existing_mv_untouched( environment_fixture_name: str, multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], @@ -823,7 +823,7 @@ def test_update_flag_v2__no_mv_values__leaves_existing_mv_untouched( environment: Environment = request.getfixturevalue(environment_fixture_name) author = AuthorData(user=admin_user) option_a, option_b, _ = multivariate_options - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -837,7 +837,7 @@ def test_update_flag_v2__no_mv_values__leaves_existing_mv_untouched( ) # When - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set(author, segment, multivariate_values=None), @@ -868,7 +868,7 @@ def test_update_flag__segment_override_with_mv__sets_mv_values( update_flag( environment, multivariate_feature, - FlagChangeSet( + FlagChangeSetOptionA( author=AuthorData(user=admin_user), enabled=True, feature_state_value="control", @@ -890,7 +890,7 @@ def test_update_flag__segment_override_with_mv__sets_mv_values( "environment_fixture_name", ["environment", "environment_v2_versioning"], ) -def test_update_flag_v2__retained_plus_passed_exceeds_100__raises( +def test_update_flag_option_b__retained_plus_passed_exceeds_100__raises( environment_fixture_name: str, multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], @@ -902,7 +902,7 @@ def test_update_flag_v2__retained_plus_passed_exceeds_100__raises( environment: Environment = request.getfixturevalue(environment_fixture_name) author = AuthorData(user=admin_user) option_a, option_b, _ = multivariate_options - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( @@ -918,7 +918,7 @@ def test_update_flag_v2__retained_plus_passed_exceeds_100__raises( # When option_b alone is raised to 100% (retained option_a 80% → 180% total) # Then it is rejected with pytest.raises(ValidationError): - update_flag_v2( + update_flag_option_b( environment, multivariate_feature, _mv_change_set( From 6f44377f6aa73f3e1f9925374db6ecb078636f36 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 7 Jul 2026 23:27:10 -0300 Subject: [PATCH 4/9] allow for partial updates --- api/features/feature_states/serializers.py | 14 +++++++------- api/features/feature_states/types.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/features/feature_states/serializers.py b/api/features/feature_states/serializers.py index 764c191ef3d7..662502eb90fa 100644 --- a/api/features/feature_states/serializers.py +++ b/api/features/feature_states/serializers.py @@ -109,8 +109,8 @@ def validate(self, attrs: FeatureValuePayload) -> FeatureValuePayload: class UpdateFlagOptionASerializer(BaseFeatureUpdateSerializer[FeatureState]): feature = FeatureIdentifierSerializer(required=True) segment = FeatureUpdateSegmentDataSerializer(required=False) - enabled = serializers.BooleanField(required=True) - value = FeatureValueSerializer(required=True) + enabled = serializers.BooleanField(required=False) + value = FeatureValueSerializer(required=False) def validate_segment(self, value: SegmentPayload) -> SegmentPayload: if value and "id" in value: @@ -138,8 +138,8 @@ def save(self, **kwargs: object) -> FeatureState: class EnvironmentDefaultSerializer(serializers.Serializer[EnvironmentDefaultPayload]): - enabled = serializers.BooleanField(required=True) - value = FeatureValueSerializer(required=True) + enabled = serializers.BooleanField(required=False) + value = FeatureValueSerializer(required=False) class MultivariateValueSerializer(serializers.Serializer[MultivariateValuePayload]): @@ -167,8 +167,8 @@ def validate_multivariate_state_values( class SegmentOverrideSerializer(serializers.Serializer[SegmentOverridePayload]): segment_id = serializers.IntegerField(required=True) priority = serializers.IntegerField(required=False, allow_null=True) - enabled = serializers.BooleanField(required=True) - value = FeatureValueSerializer(required=True) + enabled = serializers.BooleanField(required=False) + value = FeatureValueSerializer(required=False) multivariate_feature_state_values = MultivariateValueSerializer( many=True, required=False ) @@ -176,7 +176,7 @@ class SegmentOverrideSerializer(serializers.Serializer[SegmentOverridePayload]): class UpdateFlagOptionBSerializer(BaseFeatureUpdateSerializer[FlagChangeSetOptionB]): feature = FeatureIdentifierSerializer(required=True) - environment_default = EnvironmentDefaultSerializer(required=True) + environment_default = EnvironmentDefaultSerializer(required=False) segment_overrides = SegmentOverrideSerializer(many=True, required=False) def validate_segment_overrides( diff --git a/api/features/feature_states/types.py b/api/features/feature_states/types.py index 9ea06de5c86a..b2de0dc2633b 100644 --- a/api/features/feature_states/types.py +++ b/api/features/feature_states/types.py @@ -26,13 +26,13 @@ class MultivariateValuePayload(TypedDict): class EnvironmentDefaultPayload(TypedDict): - enabled: bool - value: FeatureValuePayload + enabled: NotRequired[bool] + value: NotRequired[FeatureValuePayload] class SegmentOverridePayload(TypedDict): segment_id: int priority: NotRequired[int | None] - enabled: bool - value: FeatureValuePayload + enabled: NotRequired[bool] + value: NotRequired[FeatureValuePayload] multivariate_feature_state_values: NotRequired[list[MultivariateValuePayload]] From 23e44645d120fa421293c9457de8741de41f3fad Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 7 Jul 2026 23:33:10 -0300 Subject: [PATCH 5/9] oops --- api/experimentation/services.py | 4 +++- .../features/versioning/test_update_flag_endpoints.py | 4 ++-- api/tests/unit/features/feature_states/test_serializers.py | 3 ++- .../observability/_events-catalogue.md | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index 59c7cc5a0d46..4bee062f8312 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -605,7 +605,9 @@ def _update_live_feature_state( update_multivariate_values(feature_state, change_set.multivariate_values) -def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSetOptionA) -> None: +def _update_rollout_in_place( + experiment: Experiment, change_set: FlagChangeSetOptionA +) -> None: """Write the rollout-segment override, keeping variant assignment stable. Under v2 versioning, ``update_flag`` clones the override into a fresh feature diff --git a/api/tests/integration/features/versioning/test_update_flag_endpoints.py b/api/tests/integration/features/versioning/test_update_flag_endpoints.py index 978ee10f522e..5e037973d4dc 100644 --- a/api/tests/integration/features/versioning/test_update_flag_endpoints.py +++ b/api/tests/integration/features/versioning/test_update_flag_endpoints.py @@ -1,7 +1,7 @@ """https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags""" from collections.abc import Callable -from typing import Any +from typing import Any, TypeAlias import pytest from rest_framework.test import APIClient @@ -12,7 +12,7 @@ from features.versioning.tasks import enable_v2_versioning from tests.integration.helpers import create_mv_option_with_api -type FeatureUpdatePayload = dict[str, Any] +FeatureUpdatePayload: TypeAlias = dict[str, Any] @pytest.fixture(params=["feature_versioning_v1", "feature_versioning_v2"], autouse=True) diff --git a/api/tests/unit/features/feature_states/test_serializers.py b/api/tests/unit/features/feature_states/test_serializers.py index 3ab5768b82bc..ce5f6b1feabd 100644 --- a/api/tests/unit/features/feature_states/test_serializers.py +++ b/api/tests/unit/features/feature_states/test_serializers.py @@ -10,6 +10,7 @@ UpdateFlagOptionBSerializer, validate_multivariate_state_values, ) +from features.feature_states.types import MultivariateValuePayload from features.models import Feature from projects.models import Project from segments.models import Segment @@ -268,7 +269,7 @@ def test_validate_multivariate_state_values__empty_list__is_noop( feature: Feature, ) -> None: # Given - multivariate_values: list[dict[str, typing.Any]] = [] + multivariate_values: list[MultivariateValuePayload] = [] # When / Then no exception is raised validate_multivariate_state_values(feature, multivariate_values) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 67c9b28a5885..a19e58be65c0 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -531,7 +531,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:742` + - `api/experimentation/services.py:744` Attributes: - `environment.id` @@ -540,7 +540,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:722` + - `api/experimentation/services.py:724` Attributes: - `environment.id` From 54708f370297a3294d18e842359b4f8bb6cc8b93 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 7 Jul 2026 23:55:16 -0300 Subject: [PATCH 6/9] wip --- api/features/feature_states/types.py | 17 ++++++++++++++ api/features/versioning/dataclasses.py | 31 ++++++++++++++++++-------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/api/features/feature_states/types.py b/api/features/feature_states/types.py index b2de0dc2633b..4a11f5cd5a40 100644 --- a/api/features/feature_states/types.py +++ b/api/features/feature_states/types.py @@ -25,9 +25,25 @@ class MultivariateValuePayload(TypedDict): percentage_allocation: float +class _BaseMultivariateOptionPayload(TypedDict): + percentage_allocation: float + + +class MultivariateOptionPayload(_BaseMultivariateOptionPayload): + # Environment level: absent id creates the option; value (re)sets its variant. + id: NotRequired[int] + value: NotRequired[FeatureValuePayload] + + +class SegmentOverrideMultivariateOptionPayload(_BaseMultivariateOptionPayload): + # Segment overrides only re-weight options that already exist on the feature. + id: int + + class EnvironmentDefaultPayload(TypedDict): enabled: NotRequired[bool] value: NotRequired[FeatureValuePayload] + multivariate_options: NotRequired[list[MultivariateOptionPayload]] class SegmentOverridePayload(TypedDict): @@ -36,3 +52,4 @@ class SegmentOverridePayload(TypedDict): enabled: NotRequired[bool] value: NotRequired[FeatureValuePayload] multivariate_feature_state_values: NotRequired[list[MultivariateValuePayload]] + multivariate_options: NotRequired[list[SegmentOverrideMultivariateOptionPayload]] diff --git a/api/features/versioning/dataclasses.py b/api/features/versioning/dataclasses.py index 0157a445255d..16fef4674d88 100644 --- a/api/features/versioning/dataclasses.py +++ b/api/features/versioning/dataclasses.py @@ -23,13 +23,14 @@ def is_environment_default(self) -> bool: @dataclass class FlagChangeSetOptionA: author: AuthorData - enabled: bool - feature_state_value: str - type_: FeatureValueType + enabled: bool | None = None + feature_state_value: str | None = None + type_: FeatureValueType | None = None segment_id: int | None = None segment_priority: int | None = None multivariate_values: list[MultivariateValueChangeSet] | None = None + multivariate_options: list[MultivariateOptionChangeSet] | None = None @dataclass @@ -38,12 +39,21 @@ class MultivariateValueChangeSet: percentage_allocation: float +@dataclass +class MultivariateOptionChangeSet: + percentage_allocation: float + # Absent id creates the option; value present (re)sets its variant value. + id: int | None = None + feature_state_value: str | None = None + type_: FeatureValueType | None = None + + @dataclass class SegmentOverrideChangeSet: segment_id: int - enabled: bool - feature_state_value: str - type_: FeatureValueType + enabled: bool | None = None + feature_state_value: str | None = None + type_: FeatureValueType | None = None priority: int | None = None multivariate_values: list[MultivariateValueChangeSet] | None = None @@ -51,8 +61,11 @@ class SegmentOverrideChangeSet: @dataclass class FlagChangeSetOptionB: author: AuthorData - environment_default_enabled: bool - environment_default_value: str - environment_default_type: FeatureValueType + environment_default_enabled: bool | None = None + environment_default_value: str | None = None + environment_default_type: FeatureValueType | None = None + environment_default_multivariate_options: ( + list[MultivariateOptionChangeSet] | None + ) = None segment_overrides: list[SegmentOverrideChangeSet] = field(default_factory=list) From 5dd0892c6c8a1014bc320518326d7dd5ed6cb0b1 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 8 Jul 2026 18:20:29 -0300 Subject: [PATCH 7/9] green stuff --- api/experimentation/services.py | 29 +- api/features/feature_states/serializers.py | 344 +++++++++++++----- api/features/feature_states/types.py | 52 ++- api/features/versioning/dataclasses.py | 33 +- api/features/versioning/versioning_service.py | 219 ++++++----- .../versioning/test_update_flag_endpoints.py | 175 +++++++++ .../feature_states/test_serializers.py | 10 +- ...test_unit_versioning_versioning_service.py | 10 +- .../observability/_events-catalogue.md | 4 +- 9 files changed, 660 insertions(+), 216 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index 4bee062f8312..5562c81b5fd4 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -57,10 +57,10 @@ ) from features.models import FeatureState from features.value_types import BOOLEAN, INTEGER, STRING -from features.versioning.dataclasses import FlagChangeSetOptionA +from features.versioning.dataclasses import FeatureValue, FlagChangeSetOptionA from features.versioning.versioning_service import ( + apply_feature_state_changes, update_flag, - update_multivariate_values, ) from integrations.flagsmith.client import get_openfeature_client from segments.models import Condition, Segment, SegmentRule @@ -593,18 +593,6 @@ def _get_live_rollout_override(experiment: Experiment) -> FeatureState | None: ) -def _update_live_feature_state( - feature_state: FeatureState, change_set: FlagChangeSetOptionA -) -> None: - feature_state.enabled = change_set.enabled - feature_state.save() - feature_state.feature_state_value.set_value( - change_set.feature_state_value, change_set.type_ - ) - feature_state.feature_state_value.save() - update_multivariate_values(feature_state, change_set.multivariate_values) - - def _update_rollout_in_place( experiment: Experiment, change_set: FlagChangeSetOptionA ) -> None: @@ -624,7 +612,12 @@ def _update_rollout_in_place( if experiment.environment.use_v2_feature_versioning and ( override := _get_live_rollout_override(experiment) ): - _update_live_feature_state(override, change_set) + apply_feature_state_changes( + override, + enabled=change_set.enabled, + value=change_set.value, + multivariate_values=change_set.multivariate_values, + ) return update_flag(experiment.environment, experiment.feature, change_set) @@ -643,8 +636,7 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: FlagChangeSetOptionA( author=spec.author, enabled=spec.enabled, - feature_state_value=spec.feature_state_value, - type_=spec.value_type, + value=FeatureValue(spec.feature_state_value, spec.value_type), segment_id=segment.id, multivariate_values=spec.multivariate_values, ), @@ -704,8 +696,7 @@ def enable_experiment_rollout(experiment: Experiment, author: AuthorData) -> Non FlagChangeSetOptionA( author=author, enabled=True, - feature_state_value=value["value"], - type_=value["type"], + value=FeatureValue(value["value"], value["type"]), segment_id=experiment.rollout_segment_id, ), ) diff --git a/api/features/feature_states/serializers.py b/api/features/feature_states/serializers.py index 662502eb90fa..60a4ea46adcf 100644 --- a/api/features/feature_states/serializers.py +++ b/api/features/feature_states/serializers.py @@ -1,22 +1,30 @@ -from typing import Any, TypeVar +from collections.abc import Mapping +from typing import TypeAlias, TypeVar, cast from rest_framework import serializers from core.dataclasses import AuthorData from environments.models import Environment from features.feature_states.types import ( + DeleteSegmentOverridePayload, EnvironmentDefaultPayload, FeatureIdentifierPayload, FeatureValuePayload, + MultivariateOptionPayload, MultivariateValuePayload, SegmentIdentifierPayload, + SegmentOverrideMultivariateOptionPayload, SegmentOverridePayload, SegmentPayload, + UpdateFlagOptionAPayload, + UpdateFlagOptionBPayload, ) from features.models import Feature, FeatureState from features.versioning.dataclasses import ( + FeatureValue, FlagChangeSetOptionA, FlagChangeSetOptionB, + MultivariateOptionChangeSet, MultivariateValueChangeSet, SegmentOverrideChangeSet, ) @@ -29,41 +37,30 @@ _InstanceT = TypeVar("_InstanceT") +_OptionPayloads: TypeAlias = ( + list[MultivariateOptionPayload] | list[SegmentOverrideMultivariateOptionPayload] +) + class BaseFeatureUpdateSerializer(serializers.Serializer[_InstanceT]): - @property - def environment(self) -> Environment: - environment: Environment | None = self.context.get("environment") - if not environment: - raise serializers.ValidationError("Environment context is required") - return environment - - def get_feature(self) -> Feature: - feature_data = self.validated_data["feature"] - try: - feature: Feature = Feature.objects.get( - project_id=self.environment.project_id, **feature_data - ) - return feature - except Feature.DoesNotExist: - raise serializers.ValidationError( - f"Feature '{feature_data}' not found in project" - ) + """Shared environment context and segment ownership check.""" def validate_segment_id(self, segment_id: int) -> None: if not Segment.objects.filter( - id=segment_id, project_id=self.environment.project_id + id=segment_id, project_id=_environment(self.context).project_id ).exists(): raise serializers.ValidationError( f"Segment with id {segment_id} not found in project" ) -class FeatureIdentifierSerializer(serializers.Serializer[FeatureIdentifierPayload]): +class FeatureIdentifierSerializer(serializers.Serializer[Feature]): + """Resolves a feature from exactly one of `name` or `id`.""" + name = serializers.CharField(required=False, allow_blank=False) id = serializers.IntegerField(required=False) - def validate(self, attrs: FeatureIdentifierPayload) -> FeatureIdentifierPayload: + def validate(self, attrs: FeatureIdentifierPayload) -> Feature: has_name = "name" in attrs has_id = "id" in attrs if not has_name and not has_id: @@ -72,15 +69,23 @@ def validate(self, attrs: FeatureIdentifierPayload) -> FeatureIdentifierPayload: ) if has_name and has_id: raise serializers.ValidationError("Provide either 'name' or 'id', not both") - return attrs + project_id = _environment(self.context).project_id + try: + return Feature.objects.get(project_id=project_id, **attrs) # type: ignore[no-any-return] + except Feature.DoesNotExist: + raise serializers.ValidationError(f"Feature '{attrs}' not found in project") class FeatureUpdateSegmentDataSerializer(serializers.Serializer[SegmentPayload]): + """Targets a segment override, optionally re-prioritising it.""" + id = serializers.IntegerField(required=True) priority = serializers.IntegerField(required=False, allow_null=True) class FeatureValueSerializer(serializers.Serializer[FeatureValuePayload]): + """A typed feature value, always transported as a string.""" + type = serializers.ChoiceField( choices=["integer", "string", "boolean"], required=True ) @@ -106,65 +111,111 @@ def validate(self, attrs: FeatureValuePayload) -> FeatureValuePayload: return attrs +class MultivariateOptionSerializer(serializers.Serializer[MultivariateOptionPayload]): + """An environment-level variant: absent `id` creates it; `value` (re)sets it.""" + + id = serializers.IntegerField(required=False) + percentage_allocation = serializers.FloatField( + required=True, min_value=0, max_value=100 + ) + value = FeatureValueSerializer(required=False) + + def validate(self, attrs: MultivariateOptionPayload) -> MultivariateOptionPayload: + if "id" not in attrs and "value" not in attrs: + raise serializers.ValidationError( + "A new multivariate option requires a 'value'." + ) + return attrs + + class UpdateFlagOptionASerializer(BaseFeatureUpdateSerializer[FeatureState]): + """Updates the environment default or one segment override per request.""" + feature = FeatureIdentifierSerializer(required=True) segment = FeatureUpdateSegmentDataSerializer(required=False) enabled = serializers.BooleanField(required=False) value = FeatureValueSerializer(required=False) + multivariate_options = MultivariateOptionSerializer(many=True, required=False) def validate_segment(self, value: SegmentPayload) -> SegmentPayload: if value and "id" in value: self.validate_segment_id(value["id"]) return value + def validate(self, attrs: UpdateFlagOptionAPayload) -> UpdateFlagOptionAPayload: + options = attrs.get("multivariate_options") + if options is None: + return attrs + if attrs.get("segment"): + _validate_segment_options(attrs["feature"], options) + else: + _validate_environment_options(attrs["feature"], options) + return attrs + @property def flag_change_set(self) -> FlagChangeSetOptionA: - validated_data = self.validated_data - value_data = validated_data["value"] + validated_data: UpdateFlagOptionAPayload = self.validated_data + value = _feature_value(validated_data.get("value")) segment_data = validated_data.get("segment") + segment_id = segment_data.get("id") if segment_data else None + options = validated_data.get("multivariate_options") + + multivariate_values = None + multivariate_options = None + if options is not None: + if segment_id is None: + multivariate_options = _option_change_sets(options) + else: + multivariate_values = _reweight_change_sets(options) return FlagChangeSetOptionA( author=AuthorData.from_request(self.context["request"]), - enabled=validated_data["enabled"], - feature_state_value=value_data["value"], - type_=value_data["type"], - segment_id=segment_data.get("id") if segment_data else None, + enabled=validated_data.get("enabled"), + value=value, + segment_id=segment_id, segment_priority=segment_data.get("priority") if segment_data else None, + multivariate_values=multivariate_values, + multivariate_options=multivariate_options, ) def save(self, **kwargs: object) -> FeatureState: - feature = self.get_feature() - return update_flag(self.environment, feature, self.flag_change_set) + validated_data: UpdateFlagOptionAPayload = self.validated_data + return update_flag( + _environment(self.context), validated_data["feature"], self.flag_change_set + ) class EnvironmentDefaultSerializer(serializers.Serializer[EnvironmentDefaultPayload]): + """The environment-default part of an Option B update.""" + enabled = serializers.BooleanField(required=False) value = FeatureValueSerializer(required=False) + multivariate_options = MultivariateOptionSerializer(many=True, required=False) class MultivariateValueSerializer(serializers.Serializer[MultivariateValuePayload]): + """A weight for an existing multivariate option.""" + multivariate_feature_option = serializers.IntegerField(required=True) percentage_allocation = serializers.FloatField( required=True, min_value=0, max_value=100 ) -def validate_multivariate_state_values( - feature: Feature, multivariate_values: list[MultivariateValuePayload] -) -> None: - if not multivariate_values: - return - option_ids = [mv["multivariate_feature_option"] for mv in multivariate_values] - if len(option_ids) != len(set(option_ids)): - raise serializers.ValidationError("Multivariate options must be unique") - valid = set(feature.multivariate_options.values_list("id", flat=True)) - if invalid := set(option_ids) - valid: - raise serializers.ValidationError( - f"Multivariate options {sorted(invalid)} do not belong to the feature" - ) +class SegmentOverrideMultivariateOptionSerializer( + serializers.Serializer[SegmentOverrideMultivariateOptionPayload] +): + """A re-weight of an option that already exists on the feature.""" + + id = serializers.IntegerField(required=True) + percentage_allocation = serializers.FloatField( + required=True, min_value=0, max_value=100 + ) class SegmentOverrideSerializer(serializers.Serializer[SegmentOverridePayload]): + """One segment override in an Option B update.""" + segment_id = serializers.IntegerField(required=True) priority = serializers.IntegerField(required=False, allow_null=True) enabled = serializers.BooleanField(required=False) @@ -172,9 +223,14 @@ class SegmentOverrideSerializer(serializers.Serializer[SegmentOverridePayload]): multivariate_feature_state_values = MultivariateValueSerializer( many=True, required=False ) + multivariate_options = SegmentOverrideMultivariateOptionSerializer( + many=True, required=False + ) class UpdateFlagOptionBSerializer(BaseFeatureUpdateSerializer[FlagChangeSetOptionB]): + """Updates the environment default and segment overrides in one request.""" + feature = FeatureIdentifierSerializer(required=True) environment_default = EnvironmentDefaultSerializer(required=False) segment_overrides = SegmentOverrideSerializer(many=True, required=False) @@ -197,72 +253,71 @@ def validate_segment_overrides( return value - def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - overrides: list[SegmentOverridePayload] = attrs.get("segment_overrides", []) - if any(o.get("multivariate_feature_state_values") for o in overrides): - feature = Feature.objects.filter( - project_id=self.environment.project_id, **attrs["feature"] - ).first() - if feature is not None: - for override in overrides: - mv_overrides = override.get("multivariate_feature_state_values", []) - validate_multivariate_state_values(feature, mv_overrides) + def validate(self, attrs: UpdateFlagOptionBPayload) -> UpdateFlagOptionBPayload: + feature = attrs["feature"] + + environment_default = attrs.get("environment_default", {}) + env_options = environment_default.get("multivariate_options") + if env_options is not None: + _validate_environment_options(feature, env_options) + + for override in attrs.get("segment_overrides", []): + options = override.get("multivariate_options") + if options is not None: + _validate_segment_options(feature, options) + validate_multivariate_state_values( + feature, override.get("multivariate_feature_state_values", []) + ) return attrs @property def change_set(self) -> FlagChangeSetOptionB: - validated_data = self.validated_data + validated_data: UpdateFlagOptionBPayload = self.validated_data - env_default = validated_data["environment_default"] - env_value_data = env_default["value"] + env_default = validated_data.get("environment_default", {}) + env_value = _feature_value(env_default.get("value")) + env_options = env_default.get("multivariate_options") - segment_overrides_data = validated_data.get("segment_overrides", []) segment_overrides = [] - - for override_data in segment_overrides_data: - value_data = override_data["value"] - - multivariate_data = override_data.get("multivariate_feature_state_values") - segment_override = SegmentOverrideChangeSet( - segment_id=override_data["segment_id"], - enabled=override_data["enabled"], - feature_state_value=value_data["value"], - type_=value_data["type"], - priority=override_data.get("priority"), - multivariate_values=[ - MultivariateValueChangeSet( - multivariate_feature_option_id=mv[ - "multivariate_feature_option" - ], - percentage_allocation=mv["percentage_allocation"], - ) - for mv in multivariate_data - ] - if multivariate_data - else None, + for override in validated_data.get("segment_overrides", []): + segment_overrides.append( + SegmentOverrideChangeSet( + segment_id=override["segment_id"], + enabled=override.get("enabled"), + value=_feature_value(override.get("value")), + priority=override.get("priority"), + multivariate_values=_segment_override_values(override), + ) ) - segment_overrides.append(segment_override) return FlagChangeSetOptionB( author=AuthorData.from_request(self.context["request"]), - environment_default_enabled=env_default["enabled"], - environment_default_value=env_value_data["value"], - environment_default_type=env_value_data["type"], + environment_default_enabled=env_default.get("enabled"), + environment_default_value=env_value, + environment_default_multivariate_options=( + _option_change_sets(env_options) if env_options is not None else None + ), segment_overrides=segment_overrides, ) def save(self, **kwargs: object) -> FlagChangeSetOptionB: - feature = self.get_feature() + validated_data: UpdateFlagOptionBPayload = self.validated_data change_set = self.change_set - update_flag_option_b(self.environment, feature, change_set) + update_flag_option_b( + _environment(self.context), validated_data["feature"], change_set + ) return change_set class SegmentIdentifierSerializer(serializers.Serializer[SegmentIdentifierPayload]): + """Identifies a segment by `id`.""" + id = serializers.IntegerField(required=True) class DeleteSegmentOverrideSerializer(BaseFeatureUpdateSerializer[None]): + """Removes a feature's override for a segment.""" + feature = FeatureIdentifierSerializer(required=True) segment = SegmentIdentifierSerializer(required=True) @@ -274,8 +329,119 @@ def validate_segment( return value def save(self, **kwargs: object) -> None: - feature = self.get_feature() - segment_id = self.validated_data["segment"]["id"] + validated_data: DeleteSegmentOverridePayload = self.validated_data author = AuthorData.from_request(self.context["request"]) - delete_segment_override(self.environment, feature, segment_id, author) + delete_segment_override( + _environment(self.context), + validated_data["feature"], + validated_data["segment"]["id"], + author, + ) + + +def validate_multivariate_state_values( + feature: Feature, multivariate_values: list[MultivariateValuePayload] +) -> None: + """Validate the weights reference unique options belonging to the feature.""" + option_ids = [mv["multivariate_feature_option"] for mv in multivariate_values] + if error := _option_ownership_error(feature, option_ids): + raise serializers.ValidationError(error) + + +def _environment(context: Mapping[str, object]) -> Environment: + try: + return cast(Environment, context["environment"]) + except KeyError: + raise serializers.ValidationError("Environment context is required") + + +def _validate_environment_options( + feature: Feature, options: list[MultivariateOptionPayload] +) -> None: + # The environment list is absolute, so its allocations must total <= 100% + total = sum(option["percentage_allocation"] for option in options) + if total > 100: + raise serializers.ValidationError( + { + "multivariate_options": ( + f"Multivariate allocations must not exceed 100%, got {total}%." + ) + } + ) + if error := _option_ownership_error(feature, _option_ids(options)): + raise serializers.ValidationError({"multivariate_options": error}) + + +def _validate_segment_options(feature: Feature, options: _OptionPayloads) -> None: + """Segment overrides can only re-weight options already on the feature.""" + if any("id" not in option for option in options): + raise serializers.ValidationError( + {"multivariate_options": "Segment overrides require an option 'id'."} + ) + if error := _option_ownership_error(feature, _option_ids(options)): + raise serializers.ValidationError({"multivariate_options": error}) + + +def _option_ids(options: _OptionPayloads) -> list[int]: + return list(filter(None, [option.get("id") for option in options])) + + +def _option_ownership_error(feature: Feature, option_ids: list[int]) -> str | None: + if not option_ids: + return None + if len(option_ids) != len(set(option_ids)): + return "Multivariate options must be unique" + valid = set(feature.multivariate_options.values_list("id", flat=True)) + if invalid := sorted(set(option_ids) - valid): + return f"Multivariate options {invalid} do not belong to the feature" + return None + + +def _segment_override_values( + override: SegmentOverridePayload, +) -> list[MultivariateValueChangeSet] | None: + # multivariate_options supersedes the legacy field when both are sent. + if (options := override.get("multivariate_options")) is not None: + return _reweight_change_sets(options) + if (values := override.get("multivariate_feature_state_values")) is not None: + return [ + MultivariateValueChangeSet( + multivariate_feature_option_id=value["multivariate_feature_option"], + percentage_allocation=value["percentage_allocation"], + ) + for value in values + ] + return None + + +def _option_change_sets( + options: list[MultivariateOptionPayload], +) -> list[MultivariateOptionChangeSet]: + change_sets = [] + for option in options: + change_sets.append( + MultivariateOptionChangeSet( + percentage_allocation=option["percentage_allocation"], + id=option.get("id"), + value=_feature_value(option.get("value")), + ) + ) + return change_sets + + +def _reweight_change_sets( + options: _OptionPayloads, +) -> list[MultivariateValueChangeSet]: + return [ + MultivariateValueChangeSet( + multivariate_feature_option_id=option_id, + percentage_allocation=option["percentage_allocation"], + ) + for option in options + if (option_id := option.get("id")) is not None + ] + + +def _feature_value(value: FeatureValuePayload | None) -> FeatureValue | None: + return FeatureValue(value["value"], value["type"]) if value else None diff --git a/api/features/feature_states/types.py b/api/features/feature_states/types.py index 4a11f5cd5a40..c7cd9381b1c8 100644 --- a/api/features/feature_states/types.py +++ b/api/features/feature_states/types.py @@ -1,26 +1,38 @@ from typing import Literal, NotRequired, TypedDict +from features.models import Feature + class FeatureIdentifierPayload(TypedDict): + """Identifies a feature by exactly one of `name` or `id`.""" + name: NotRequired[str] id: NotRequired[int] class FeatureValuePayload(TypedDict): + """A typed feature value, always transported as a string.""" + type: Literal["integer", "string", "boolean"] value: str class SegmentPayload(TypedDict): + """Targets a segment override, optionally re-prioritising it.""" + id: int priority: NotRequired[int | None] class SegmentIdentifierPayload(TypedDict): + """Identifies a segment by `id`.""" + id: int class MultivariateValuePayload(TypedDict): + """A weight for an existing multivariate option.""" + multivariate_feature_option: int percentage_allocation: float @@ -30,26 +42,62 @@ class _BaseMultivariateOptionPayload(TypedDict): class MultivariateOptionPayload(_BaseMultivariateOptionPayload): - # Environment level: absent id creates the option; value (re)sets its variant. + """An environment-level variant: absent `id` creates it; `value` (re)sets it.""" + id: NotRequired[int] value: NotRequired[FeatureValuePayload] class SegmentOverrideMultivariateOptionPayload(_BaseMultivariateOptionPayload): - # Segment overrides only re-weight options that already exist on the feature. + """A re-weight of an option that already exists on the feature.""" + id: int class EnvironmentDefaultPayload(TypedDict): + """The environment-default part of a flag update (Option B). + + https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags + """ + enabled: NotRequired[bool] value: NotRequired[FeatureValuePayload] multivariate_options: NotRequired[list[MultivariateOptionPayload]] class SegmentOverridePayload(TypedDict): + """One segment override in a flag update (Option B). + + https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags""" + segment_id: int priority: NotRequired[int | None] enabled: NotRequired[bool] value: NotRequired[FeatureValuePayload] multivariate_feature_state_values: NotRequired[list[MultivariateValuePayload]] multivariate_options: NotRequired[list[SegmentOverrideMultivariateOptionPayload]] + + +class UpdateFlagOptionAPayload(TypedDict): + """Flag update (Option A) request body, with `feature` resolved.""" + + feature: Feature + segment: NotRequired[SegmentPayload] + enabled: NotRequired[bool] + value: NotRequired[FeatureValuePayload] + multivariate_options: NotRequired[list[MultivariateOptionPayload]] + + +class UpdateFlagOptionBPayload(TypedDict): + """Flag update (Option B) request body, with `feature` resolved.""" + + feature: Feature + environment_default: NotRequired[EnvironmentDefaultPayload] + segment_overrides: NotRequired[list[SegmentOverridePayload]] + + +class DeleteSegmentOverridePayload(TypedDict): + """Delete request body, with `feature` resolved.""" + + feature: Feature + segment: SegmentIdentifierPayload diff --git a/api/features/versioning/dataclasses.py b/api/features/versioning/dataclasses.py index 16fef4674d88..c0fb8e1a56d2 100644 --- a/api/features/versioning/dataclasses.py +++ b/api/features/versioning/dataclasses.py @@ -10,6 +10,8 @@ class Conflict(BaseModel): + """A change published since a change set's creation that touches its target.""" + segment_id: int | None = None original_cr_id: int | None = None published_at: datetime | None = None @@ -20,50 +22,65 @@ def is_environment_default(self) -> bool: return self.segment_id is None +@dataclass(frozen=True) +class FeatureValue: + """A typed feature value, transported as a string.""" + + value: str + type_: FeatureValueType + + @dataclass class FlagChangeSetOptionA: + """A single-state change: `None` parts are left intact on the target.""" + author: AuthorData enabled: bool | None = None - feature_state_value: str | None = None - type_: FeatureValueType | None = None + value: FeatureValue | None = None segment_id: int | None = None segment_priority: int | None = None multivariate_values: list[MultivariateValueChangeSet] | None = None + # Environment-level reconcile; segment re-weights travel in multivariate_values. multivariate_options: list[MultivariateOptionChangeSet] | None = None @dataclass class MultivariateValueChangeSet: + """A weight to set for an existing multivariate option.""" + multivariate_feature_option_id: int percentage_allocation: float @dataclass class MultivariateOptionChangeSet: + """An environment-level variant to create, update, or re-weight.""" + percentage_allocation: float # Absent id creates the option; value present (re)sets its variant value. id: int | None = None - feature_state_value: str | None = None - type_: FeatureValueType | None = None + value: FeatureValue | None = None @dataclass class SegmentOverrideChangeSet: + """A segment override change: `None` parts are left intact on the target.""" + segment_id: int enabled: bool | None = None - feature_state_value: str | None = None - type_: FeatureValueType | None = None + value: FeatureValue | None = None priority: int | None = None multivariate_values: list[MultivariateValueChangeSet] | None = None @dataclass class FlagChangeSetOptionB: + """A whole-feature change: environment default plus segment overrides.""" + author: AuthorData environment_default_enabled: bool | None = None - environment_default_value: str | None = None - environment_default_type: FeatureValueType | None = None + environment_default_value: FeatureValue | None = None environment_default_multivariate_options: ( list[MultivariateOptionChangeSet] | None ) = None diff --git a/api/features/versioning/versioning_service.py b/api/features/versioning/versioning_service.py index a9d141176771..e1af1f89bb9d 100644 --- a/api/features/versioning/versioning_service.py +++ b/api/features/versioning/versioning_service.py @@ -1,18 +1,23 @@ import typing from common.core.utils import using_database_replica +from django.db import transaction from django.db.models import Prefetch, Q, QuerySet from django.utils import timezone from rest_framework.exceptions import NotFound, ValidationError from core.dataclasses import AuthorData from environments.models import Environment -from features.feature_states.models import FeatureValueType from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue -from features.multivariate.models import MultivariateFeatureStateValue +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) from features.versioning.dataclasses import ( + FeatureValue, FlagChangeSetOptionA, FlagChangeSetOptionB, + MultivariateOptionChangeSet, MultivariateValueChangeSet, ) from features.versioning.exceptions import DirectFeatureStateWriteNotAllowedError @@ -22,12 +27,14 @@ def require_direct_state_write( environment: Environment, *, is_identity_override: bool ) -> None: + """Forbid direct feature-state writes where v2 versioning owns the history.""" if is_identity_override or not environment.use_v2_feature_versioning: return raise DirectFeatureStateWriteNotAllowedError() def require_direct_state_write_for_state(feature_state: FeatureState) -> None: + """Forbid direct writes to a feature state owned by published v2 history.""" # FS rows attached to an unpublished EFV are a draft, so direct mutation is # part of the versioning flow rather than a bypass of it. efv = feature_state.environment_feature_version @@ -92,6 +99,7 @@ def get_environment_flags_dict( key_function: typing.Callable[[FeatureState], tuple] = None, # type: ignore[type-arg,assignment] from_replica: bool = False, ) -> dict[tuple | str | int, FeatureState]: # type: ignore[type-arg] + """Return the latest live feature states, keyed by the given key function.""" key_function = key_function or _get_distinct_key # type: ignore[truthy-function] feature_states = _get_feature_states_queryset( @@ -119,6 +127,7 @@ def get_environment_flags_dict( def get_current_live_environment_feature_version( environment_id: int, feature_id: int ) -> EnvironmentFeatureVersion | None: + """Return the feature's latest published, live version in the environment.""" return ( # type: ignore[no-any-return] EnvironmentFeatureVersion.objects.filter( environment_id=environment_id, @@ -134,9 +143,10 @@ def get_current_live_environment_feature_version( def update_flag( environment: Environment, feature: Feature, change_set: FlagChangeSetOptionA ) -> FeatureState: - if environment.use_v2_feature_versioning: - return _update_flag_for_versioning_v2(environment, feature, change_set) - else: + """Apply a change to the environment default or one segment override.""" + with transaction.atomic(): + if environment.use_v2_feature_versioning: + return _update_flag_for_versioning_v2(environment, feature, change_set) return _update_flag_for_versioning_v1(environment, feature, change_set) @@ -171,7 +181,7 @@ def _update_flag_for_versioning_v2( environment=environment, feature_segment=feature_segment, environment_feature_version=new_version, - enabled=change_set.enabled, + enabled=bool(change_set.enabled), ) else: # Environment default - always exists @@ -180,15 +190,7 @@ def _update_flag_for_versioning_v2( identity_id=None, ) - target_feature_state.enabled = change_set.enabled - target_feature_state.save() - - _update_feature_state_value( - target_feature_state.feature_state_value, - change_set.feature_state_value, - change_set.type_, - ) - update_multivariate_values(target_feature_state, change_set.multivariate_values) + _apply_flag_change_set(feature, target_feature_state, change_set) if change_set.segment_id is not None and change_set.segment_priority is not None: _update_segment_priority(target_feature_state, change_set.segment_priority) @@ -228,20 +230,13 @@ def _update_flag_for_versioning_v1( feature=feature, environment=environment, feature_segment=feature_segment, - enabled=change_set.enabled, + enabled=bool(change_set.enabled), ) else: assert len(latest_feature_states) == 1 target_feature_state = list(latest_feature_states.values())[0] - target_feature_state.enabled = change_set.enabled - target_feature_state.save() - _update_feature_state_value( - target_feature_state.feature_state_value, - change_set.feature_state_value, - change_set.type_, - ) - update_multivariate_values(target_feature_state, change_set.multivariate_values) + _apply_flag_change_set(feature, target_feature_state, change_set) if change_set.segment_id is not None and change_set.segment_priority is not None: _update_segment_priority(target_feature_state, change_set.segment_priority) @@ -249,10 +244,8 @@ def _update_flag_for_versioning_v1( return target_feature_state -def _update_feature_state_value( - fsv: FeatureStateValue, value: str, type_: FeatureValueType -) -> None: - fsv.set_value(value, type_) +def _update_feature_state_value(fsv: FeatureStateValue, value: FeatureValue) -> None: + fsv.set_value(value.value, value.type_) fsv.save() @@ -260,6 +253,7 @@ def update_multivariate_values( feature_state: FeatureState, values: list[MultivariateValueChangeSet] | None, ) -> None: + """Create or re-weight the state's allocations; omitted options are kept.""" if values is None: return @@ -293,11 +287,100 @@ def update_multivariate_values( mv.save() +def apply_feature_state_changes( + feature_state: FeatureState, + *, + enabled: bool | None, + value: FeatureValue | None, + multivariate_values: list[MultivariateValueChangeSet] | None, +) -> None: + """Apply only the provided parts of a change; omissions are left intact.""" + if enabled is not None: + feature_state.enabled = enabled + feature_state.save() + if value is not None: + _update_feature_state_value(feature_state.feature_state_value, value) + update_multivariate_values(feature_state, multivariate_values) + + +def _reconcile_environment_multivariate_options( + feature: Feature, + environment_default_state: FeatureState, + options: list[MultivariateOptionChangeSet], +) -> None: + """Create, update, or delete environment-level MVs from `options` as SoT""" + kept_ids = {option.id for option in options if option.id is not None} + for obsolete in feature.multivariate_options.exclude(id__in=kept_ids): + obsolete.delete() + + allocations = [] + for option in options: + if option.id is None: + assert option.value is not None + mv_option = MultivariateFeatureOption( + feature=feature, + default_percentage_allocation=option.percentage_allocation, + ) + mv_option.set_value(option.value.value, option.value.type_) + mv_option.save() + option_id = mv_option.id + else: + option_id = option.id + if option.value is not None: + mv_option = feature.multivariate_options.get(id=option_id) + mv_option.set_value(option.value.value, option.value.type_) + mv_option.save() + allocations.append( + MultivariateValueChangeSet( + multivariate_feature_option_id=option_id, + percentage_allocation=option.percentage_allocation, + ) + ) + + update_multivariate_values(environment_default_state, allocations) + + +def _apply_flag_change_set( + feature: Feature, + feature_state: FeatureState, + change_set: FlagChangeSetOptionA, +) -> None: + apply_feature_state_changes( + feature_state, + enabled=change_set.enabled, + value=change_set.value, + multivariate_values=change_set.multivariate_values, + ) + if change_set.multivariate_options is not None: + _reconcile_environment_multivariate_options( + feature, feature_state, change_set.multivariate_options + ) + + +def _apply_environment_default( + feature: Feature, + feature_state: FeatureState, + change_set: FlagChangeSetOptionB, +) -> None: + apply_feature_state_changes( + feature_state, + enabled=change_set.environment_default_enabled, + value=change_set.environment_default_value, + multivariate_values=None, + ) + if change_set.environment_default_multivariate_options is not None: + _reconcile_environment_multivariate_options( + feature, + feature_state, + change_set.environment_default_multivariate_options, + ) + + def _create_segment_override( feature: Feature, environment: Environment, segment_id: int, - enabled: bool, + enabled: bool | None, priority: int | None, version: EnvironmentFeatureVersion | None = None, ) -> FeatureState: @@ -318,7 +401,7 @@ def _create_segment_override( environment=environment, feature_segment=feature_segment, environment_feature_version=version, - enabled=enabled, + enabled=bool(enabled), ) return segment_state @@ -333,10 +416,12 @@ def _update_segment_priority(feature_state: FeatureState, priority: int) -> None def update_flag_option_b( environment: Environment, feature: Feature, change_set: FlagChangeSetOptionB ) -> None: - if environment.use_v2_feature_versioning: - _update_flag_option_b_for_versioning_v2(environment, feature, change_set) - else: - _update_flag_option_b_for_versioning_v1(environment, feature, change_set) + """Apply a change to the environment default and its segment overrides.""" + with transaction.atomic(): + if environment.use_v2_feature_versioning: + _update_flag_option_b_for_versioning_v2(environment, feature, change_set) + else: + _update_flag_option_b_for_versioning_v1(environment, feature, change_set) def _update_flag_option_b_for_versioning_v2( @@ -352,30 +437,13 @@ def _update_flag_option_b_for_versioning_v2( env_default_state = new_version.feature_states.get( feature_segment__isnull=True, identity_id=None ) - env_default_state.enabled = change_set.environment_default_enabled - env_default_state.save() - - _update_feature_state_value( - env_default_state.feature_state_value, - change_set.environment_default_value, - change_set.environment_default_type, - ) + _apply_environment_default(feature, env_default_state, change_set) for override in change_set.segment_overrides: try: segment_state = new_version.feature_states.get( feature_segment__segment_id=override.segment_id ) - segment_state.enabled = override.enabled - segment_state.save() - - _update_feature_state_value( - segment_state.feature_state_value, - override.feature_state_value, - override.type_, - ) - update_multivariate_values(segment_state, override.multivariate_values) - if override.priority is not None: _update_segment_priority(segment_state, override.priority) except FeatureState.DoesNotExist: @@ -387,13 +455,12 @@ def _update_flag_option_b_for_versioning_v2( priority=override.priority, version=new_version, ) - - _update_feature_state_value( - segment_state.feature_state_value, - override.feature_state_value, - override.type_, - ) - update_multivariate_values(segment_state, override.multivariate_values) + apply_feature_state_changes( + segment_state, + enabled=override.enabled, + value=override.value, + multivariate_values=override.multivariate_values, + ) new_version.publish( published_by=change_set.author.user, @@ -412,14 +479,7 @@ def _update_flag_option_b_for_versioning_v1( assert len(env_default_states) == 1 env_default_state = list(env_default_states.values())[0] - env_default_state.enabled = change_set.environment_default_enabled - env_default_state.save() - - _update_feature_state_value( - env_default_state.feature_state_value, - change_set.environment_default_value, - change_set.environment_default_type, - ) + _apply_environment_default(feature, env_default_state, change_set) for override in change_set.segment_overrides: # TODO: optimise this once this is out of the @@ -439,29 +499,19 @@ def _update_flag_option_b_for_versioning_v1( priority=override.priority, version=None, # V1 versioning doesn't use versions ) - - _update_feature_state_value( - segment_state.feature_state_value, - override.feature_state_value, - override.type_, - ) - update_multivariate_values(segment_state, override.multivariate_values) else: assert len(segment_states) == 1 segment_state = list(segment_states.values())[0] - segment_state.enabled = override.enabled - segment_state.save() - - _update_feature_state_value( - segment_state.feature_state_value, - override.feature_state_value, - override.type_, - ) - update_multivariate_values(segment_state, override.multivariate_values) - if override.priority is not None: _update_segment_priority(segment_state, override.priority) + apply_feature_state_changes( + segment_state, + enabled=override.enabled, + value=override.value, + multivariate_values=override.multivariate_values, + ) + def delete_segment_override( environment: "Environment", @@ -469,6 +519,7 @@ def delete_segment_override( segment_id: int, author: AuthorData, ) -> None: + """Remove a feature's segment override, versioning the removal under v2.""" if environment.use_v2_feature_versioning: _delete_segment_override_v2(environment, feature, segment_id, author) else: diff --git a/api/tests/integration/features/versioning/test_update_flag_endpoints.py b/api/tests/integration/features/versioning/test_update_flag_endpoints.py index 5e037973d4dc..91a125610ec3 100644 --- a/api/tests/integration/features/versioning/test_update_flag_endpoints.py +++ b/api/tests/integration/features/versioning/test_update_flag_endpoints.py @@ -349,3 +349,178 @@ def test_update_flag__multivariate_percentage_allocation_exceeds_100__responds_4 assert response.status_code == 400 assert "multivariate_options" in response.json() assert not MultivariateFeatureOption.objects.filter(feature_id=feature).exists() + + +@pytest.mark.parametrize( + "endpoint,payload,expected_error", + [ + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "multivariate_options": [{"percentage_allocation": 50}], + }, + "requires a 'value'", + id="option_a-environment-new-option-without-value", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [{"percentage_allocation": 50}], + }, + }, + "requires a 'value'", + id="option_b-environment-new-option-without-value", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "multivariate_options": [{"id": 999999, "percentage_allocation": 50}], + }, + "do not belong to the feature", + id="option_a-environment-unknown-option", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 50} + ], + }, + }, + "do not belong to the feature", + id="option_b-environment-unknown-option", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 20}, + {"id": 999999, "percentage_allocation": 30}, + ], + }, + "must be unique", + id="option_a-environment-duplicate-option", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment: { + "feature": {"id": feature}, + "environment_default": { + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 20}, + {"id": 999999, "percentage_allocation": 30}, + ], + }, + }, + "must be unique", + id="option_b-environment-duplicate-option", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "segment": {"id": segment}, + "multivariate_options": [ + { + "percentage_allocation": 50, + "value": {"type": "string", "value": "variant"}, + } + ], + }, + "require an option 'id'", + id="option_a-segment-option-without-id", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "segment": {"id": segment}, + "multivariate_options": [{"id": 999999, "percentage_allocation": 50}], + }, + "do not belong to the feature", + id="option_a-segment-unknown-option", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment: { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 50} + ], + }, + ], + }, + "do not belong to the feature", + id="option_b-segment-unknown-option", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"id": feature}, + "segment": {"id": segment}, + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 20}, + {"id": 999999, "percentage_allocation": 30}, + ], + }, + "must be unique", + id="option_a-segment-duplicate-option", + ), + pytest.param( + "update-flag-v2", + lambda feature, segment: { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "multivariate_options": [ + {"id": 999999, "percentage_allocation": 20}, + {"id": 999999, "percentage_allocation": 30}, + ], + }, + ], + }, + "must be unique", + id="option_b-segment-duplicate-option", + ), + pytest.param( + "update-flag-v1", + lambda feature, segment: { + "feature": {"name": "missing-feature"}, + "multivariate_options": [{"id": 999999, "percentage_allocation": 50}], + }, + "not found in project", + id="option_a-unknown-feature", + ), + ], +) +def test_update_flag__invalid_multivariate_options__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, + endpoint: str, + payload: Callable[[int, int], FeatureUpdatePayload], + expected_error: str, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/{endpoint}/", + payload(feature, segment), + format="json", + ) + + # Then + assert response.status_code == 400 + assert expected_error in str(response.json()) diff --git a/api/tests/unit/features/feature_states/test_serializers.py b/api/tests/unit/features/feature_states/test_serializers.py index ce5f6b1feabd..222b4322e86a 100644 --- a/api/tests/unit/features/feature_states/test_serializers.py +++ b/api/tests/unit/features/feature_states/test_serializers.py @@ -1,7 +1,6 @@ import typing import pytest -from rest_framework import serializers from environments.models import Environment from features.feature_states.serializers import ( @@ -16,7 +15,7 @@ from segments.models import Segment -def test_get_feature__no_environment_in_context__raises_validation_error( +def test_update_flag_serializer__no_environment_in_context__returns_invalid( feature: Feature, ) -> None: # Given @@ -28,14 +27,13 @@ def test_get_feature__no_environment_in_context__raises_validation_error( }, context={}, # No environment ) - serializer.is_valid() # When - with pytest.raises(serializers.ValidationError) as exc_info: - serializer.get_feature() + is_valid = serializer.is_valid() # Then - assert "Environment context is required" in str(exc_info.value) + assert is_valid is False + assert "Environment context is required" in str(serializer.errors) def test_validate_segment_overrides__empty_list__returns_empty_list() -> None: diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index 2c9fd6936f1d..ec1ca2a2fae2 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -16,6 +16,7 @@ MultivariateFeatureStateValue, ) from features.versioning.dataclasses import ( + FeatureValue, FlagChangeSetOptionA, FlagChangeSetOptionB, MultivariateValueChangeSet, @@ -643,14 +644,12 @@ def _mv_change_set( return FlagChangeSetOptionB( author=author, environment_default_enabled=True, - environment_default_value="control", - environment_default_type="string", + environment_default_value=FeatureValue("control", "string"), segment_overrides=[ SegmentOverrideChangeSet( segment_id=segment.id, enabled=True, - feature_state_value="control", - type_="string", + value=FeatureValue("control", "string"), multivariate_values=multivariate_values, ) ], @@ -871,8 +870,7 @@ def test_update_flag__segment_override_with_mv__sets_mv_values( FlagChangeSetOptionA( author=AuthorData(user=admin_user), enabled=True, - feature_state_value="control", - type_="string", + value=FeatureValue("control", "string"), segment_id=segment.id, multivariate_values=[ MultivariateValueChangeSet(option_a.id, 70.0), diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index a19e58be65c0..f57f705da65a 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -531,7 +531,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:744` + - `api/experimentation/services.py:735` Attributes: - `environment.id` @@ -540,7 +540,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:724` + - `api/experimentation/services.py:715` Attributes: - `environment.id` From 8d5469c38d04cd393b64f82dd944d6630c72e508 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 8 Jul 2026 18:39:20 -0300 Subject: [PATCH 8/9] make sync --- mcp/src/flagsmith_mcp/openapi.json | 2 + openapi.yaml | 75 +++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 008774b33d4d..313a05faa006 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -5092,6 +5092,7 @@ } }, "FeatureValue": { + "description": "A typed feature value, always transported as a string.", "type": "object", "properties": { "type": { @@ -5624,6 +5625,7 @@ ] }, "MultivariateValue": { + "description": "A weight for an existing multivariate option.", "type": "object", "properties": { "multivariate_feature_option": { diff --git a/openapi.yaml b/openapi.yaml index 10083805de9d..7a50cee3581f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -99,13 +99,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpdateFlag' + $ref: '#/components/schemas/UpdateFlagOptionA' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/UpdateFlag' + $ref: '#/components/schemas/UpdateFlagOptionA' multipart/form-data: schema: - $ref: '#/components/schemas/UpdateFlag' + $ref: '#/components/schemas/UpdateFlagOptionA' responses: '204': description: No response body @@ -162,13 +162,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpdateFlagV2' + $ref: '#/components/schemas/UpdateFlagOptionB' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/UpdateFlagV2' + $ref: '#/components/schemas/UpdateFlagOptionB' multipart/form-data: schema: - $ref: '#/components/schemas/UpdateFlagV2' + $ref: '#/components/schemas/UpdateFlagOptionB' responses: '204': description: No response body @@ -19222,6 +19222,7 @@ components: required: - key DeleteSegmentOverride: + description: Removes a feature's override for a segment. type: object properties: feature: @@ -19467,15 +19468,17 @@ components: required: - name EnvironmentDefault: + description: The environment-default part of an Option B update. type: object properties: enabled: type: boolean value: $ref: '#/components/schemas/FeatureValue' - required: - - enabled - - value + multivariate_options: + type: array + items: + $ref: '#/components/schemas/MultivariateOption' EnvironmentFeatureVersion: type: object properties: @@ -20473,6 +20476,7 @@ components: - name - project FeatureIdentifier: + description: Resolves a feature from exactly one of `name` or `id`. type: object properties: name: @@ -20856,6 +20860,7 @@ components: - boolean - 'null' FeatureUpdateSegmentData: + description: 'Targets a segment override, optionally re-prioritising it.' type: object properties: id: @@ -20867,6 +20872,7 @@ components: required: - id FeatureValue: + description: 'A typed feature value, always transported as a string.' type: object properties: type: @@ -21951,6 +21957,21 @@ components: required: - multivariate_feature_option - percentage_allocation + MultivariateOption: + description: 'An environment-level variant: absent `id` creates it; `value` (re)sets it.' + type: object + properties: + id: + type: integer + percentage_allocation: + type: number + format: double + maximum: 100 + minimum: 0 + value: + $ref: '#/components/schemas/FeatureValue' + required: + - percentage_allocation MultivariateOptionValues: type: object properties: @@ -21965,6 +21986,7 @@ components: - type: 'null' readOnly: true MultivariateValue: + description: A weight for an existing multivariate option. type: object properties: multivariate_feature_option: @@ -26182,6 +26204,7 @@ components: required: - api_key SegmentIdentifier: + description: Identifies a segment by `id`. type: object properties: id: @@ -26229,6 +26252,7 @@ components: format: date-time readOnly: true SegmentOverride: + description: One segment override in an Option B update. type: object properties: segment_id: @@ -26245,10 +26269,26 @@ components: type: array items: $ref: '#/components/schemas/MultivariateValue' + multivariate_options: + type: array + items: + $ref: '#/components/schemas/SegmentOverrideMultivariateOption' required: - - enabled - segment_id - - value + SegmentOverrideMultivariateOption: + description: A re-weight of an option that already exists on the feature. + type: object + properties: + id: + type: integer + percentage_allocation: + type: number + format: double + maximum: 100 + minimum: 0 + required: + - id + - percentage_allocation SegmentRule: description: Adds nested create feature type: object @@ -27009,7 +27049,8 @@ components: allOf: - $ref: '#/components/schemas/LifecycleStageEnum' readOnly: true - UpdateFlag: + UpdateFlagOptionA: + description: Updates the environment default or one segment override per request. type: object properties: feature: @@ -27020,11 +27061,14 @@ components: type: boolean value: $ref: '#/components/schemas/FeatureValue' + multivariate_options: + type: array + items: + $ref: '#/components/schemas/MultivariateOption' required: - - enabled - feature - - value - UpdateFlagV2: + UpdateFlagOptionB: + description: Updates the environment default and segment overrides in one request. type: object properties: feature: @@ -27036,7 +27080,6 @@ components: items: $ref: '#/components/schemas/SegmentOverride' required: - - environment_default - feature UpdateSubscription: type: object From 8c250925b5f3e1efb14e91cc4ebde950a7618a87 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 8 Jul 2026 19:37:11 -0300 Subject: [PATCH 9/9] oh snap it was no slop --- api/tests/integration/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 4b7b74c69d08..18051756cd0c 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -105,6 +105,11 @@ def environment_api_key() -> str: return create_hash() +@pytest.fixture() +def environment_api_key_str(environment_api_key: str) -> str: + return environment_api_key + + @pytest.fixture() def environment_name() -> str: return "Test Environment"