From 5c2b471866a1d8cd8e9b7c00e325e5d98940fffb Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 24 Jul 2026 19:30:01 -0300 Subject: [PATCH 01/39] first the dream --- .../admin-api/updating-flags.md | 247 ------------------ .../docs/managing-flags/feature-versioning.md | 2 +- docs/docs/managing-flags/updating-flags.md | 211 +++++++++++++++ 3 files changed, 212 insertions(+), 248 deletions(-) delete mode 100644 docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md create mode 100644 docs/docs/managing-flags/updating-flags.md 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 deleted file mode 100644 index 6a8ca75fbfb1..000000000000 --- a/docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md +++ /dev/null @@ -1,247 +0,0 @@ ---- -title: Updating Flags (Experimental) -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. - -:::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. - -::: - -We're evaluating two approaches for updating flags — **Option A** (one change per request) and **Option B** (everything -in one request). Each scenario below shows both. Try them and -[let us know which works better for you](https://github.com/Flagsmith/flagsmith/issues/6233). - -**Common details:** - -- Identify features by `name` or `id` (pick one, not both). -- 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"}` | - ---- - -## Toggle a flag on or off - -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) - -```bash -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": "maintenance_mode"}, - "enabled": true, - "value": {"type": "boolean", "value": "true"} - }' -``` - -**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/' \ - -H 'Authorization: Api-Key ' \ - -H 'Content-Type: application/json' \ - -d '{ - "feature": {"name": "maintenance_mode"}, - "environment_default": { - "enabled": true, - "value": {"type": "boolean", "value": "true"} - } - }' -``` - ---- - -## Update a feature value - -Change a feature's value — for example, setting a rate limit. - -**Option A** - -```bash -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": "api_rate_limit"}, - "enabled": true, - "value": {"type": "integer", "value": "1000"} - }' -``` - -**Option B** - -```bash -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": "api_rate_limit"}, - "environment_default": { - "enabled": true, - "value": {"type": "integer", "value": "1000"} - } - }' -``` - ---- - -## Roll out a feature to a segment - -Enable a feature for a specific segment (e.g. beta users) while keeping it off for everyone else. - -**Option A** - -```bash -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_checkout"}, - "segment": {"id": 456}, - "enabled": true, - "value": {"type": "boolean", "value": "true"} - }' -``` - -**Option B** — single request: - -```bash -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_checkout"}, - "environment_default": { - "enabled": false, - "value": {"type": "boolean", "value": "false"} - }, - "segment_overrides": [ - { - "segment_id": 456, - "enabled": true, - "value": {"type": "boolean", "value": "true"} - } - ] - }' -``` - -The `priority` field on segment overrides is optional. Omit it to add at the lowest priority. Priority `1` is highest. - ---- - -## Configure multiple segment overrides - -Set different values per segment — for example, pricing tiers. - -**Option A** — one request per segment override plus one for the default: - -```bash -# Default -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": "pricing_tier"}, - "enabled": true, - "value": {"type": "string", "value": "standard"} - }' - -# Enterprise segment (highest priority) -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": "pricing_tier"}, - "segment": {"id": 101, "priority": 1}, - "enabled": true, - "value": {"type": "string", "value": "enterprise"} - }' - -# Premium segment -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": "pricing_tier"}, - "segment": {"id": 202, "priority": 2}, - "enabled": true, - "value": {"type": "string", "value": "premium"} - }' -``` - -**Option B** — single request: - -```bash -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": "pricing_tier"}, - "environment_default": { - "enabled": true, - "value": {"type": "string", "value": "standard"} - }, - "segment_overrides": [ - { - "segment_id": 101, - "priority": 1, - "enabled": true, - "value": {"type": "string", "value": "enterprise"} - }, - { - "segment_id": 202, - "priority": 2, - "enabled": true, - "value": {"type": "string", "value": "premium"} - } - ] - }' -``` - ---- - -## Remove a segment override - -A separate endpoint for removing a segment override from a feature: - -[`POST /api/experiments/environments/{environment_key}/delete-segment-override/`](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_delete_segment_override_create) - -```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/delete-segment-override/' \ - -H 'Authorization: Api-Key ' \ - -H 'Content-Type: application/json' \ - -d '{ - "feature": {"name": "pricing_tier"}, - "segment": {"id": 202} - }' -``` - ---- - -## 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/) | diff --git a/docs/docs/managing-flags/feature-versioning.md b/docs/docs/managing-flags/feature-versioning.md index 1f842e3d36d5..2946a51447c4 100644 --- a/docs/docs/managing-flags/feature-versioning.md +++ b/docs/docs/managing-flags/feature-versioning.md @@ -27,7 +27,7 @@ Enabling Feature Versioning v2 on an environment is irreversible. To produce a new published version on a v2 environment, use one of: -- **The experimental [update-flag endpoints](/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags)** (`update-flag-v1`, `update-flag-v2`, `delete-segment-override`). These accept the same payloads as on v1 environments and publish a new version per call on v2 environments. +- **The experimental [update-flag endpoints](/managing-flags/updating-flags)** (`update-flag-v1`, `update-flag-v2`, `delete-segment-override`). These accept the same payloads as on v1 environments and publish a new version per call on v2 environments. - **The new versioning endpoint family**: - `GET /environments/{env}/features/{feature}/versions/` — list versions for a feature. - `POST /environments/{env}/features/{feature}/versions/` — create a draft version. diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md new file mode 100644 index 000000000000..885ba41efc8d --- /dev/null +++ b/docs/docs/managing-flags/updating-flags.md @@ -0,0 +1,211 @@ +--- +title: Updating Flags (Experimental) +sidebar_label: Updating Flags (Experimental) +sidebar_position: 6 +--- + +The `/api/experiments/environments/{environment_key}/update-flag/` endpoint lets you update feature flags, segment +overrides, and variant allocations via the Admin API. + +A successful response is always **204 No Content**. The request body is a JSON object with the following fields: + +- `feature` (required) — the feature to update, identified by `name` or `id`. +- `environment_default` (optional) — the default state of the feature in the environment. +- `segment_overrides` (optional) — a list of segment overrides for the feature. + +Any attribute omitted in the payload will be left unchanged. + +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"}` | + +Learn more in the +[API specification](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag). + +:::caution + +**This endpoint is experimental and may change without notice.** It cannot be used when +[change requests](/administration-and-security/governance-and-compliance/change-requests) are enabled. + +::: + +## Examples + +### Toggle a flag on or off + +The simplest case — flip a feature flag in an environment. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "maintenance_mode"}, + "environment_default": {"enabled": true} + }' +``` + +### Update a feature value + +Change a feature's value — for example, setting a rate limit. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "api_rate_limit"}, + "environment_default": { + "enabled": true, + "value": {"type": "integer", "value": "1000"} + } + }' +``` + +### Roll out a feature to a segment + +Enable a feature for a specific segment (e.g. beta users) while keeping it off for everyone else. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_checkout"}, + "environment_default": { + "enabled": false, + "value": {"type": "boolean", "value": "false"} + }, + "segment_overrides": [ + { + "segment_id": 456, + "enabled": true, + "value": {"type": "boolean", "value": "true"} + } + ] + }' +``` + +The `priority` field in segment overrides is optional. The lowest number has the highest priority. Omit it to add at the +lowest priority. + +### Configure multiple segment overrides + +Set different values per segment — for example, pricing tiers. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "pricing_tier"}, + "environment_default": { + "enabled": true, + "value": {"type": "string", "value": "standard"} + }, + "segment_overrides": [ + { + "segment_id": 101, + "priority": 1, + "enabled": true, + "value": {"type": "string", "value": "enterprise"} + }, + { + "segment_id": 202, + "priority": 2, + "enabled": true, + "value": {"type": "string", "value": "premium"} + } + ] + }' +``` + +### Configure A/B/n experiments (variants) + +Set up features with weighted variants and customise weights per segment. + +The `variants` list in `environment_default` defines the available variants for the feature, and their default weights +in the environment. Each variant is identified by a `key` (slug format). Omitting a variant from the list deletes it in +the environment, and in any segment overrides. + +A `weight` is a fraction between 0 and 1. Any weight not allocated to variants serves the flag's default `value`. + +The `variants` list in `segment_overrides` can only re-weight existing variants. Variants omitted from it keep their +current weights for that segment. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -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_gateway"}, + "variants": [ + { + "key": "new_gateway_a", + "weight": 0.1, + "value": {"type": "string", "value": "sharp_payments"} + }, + { + "key": "new_gateway_b", + "weight": 0.1, + "value": {"type": "string", "value": "e_z_pay"} + } + ] + } + }' +``` + +Within the same request as above, or separately, you can also set different weights for a segment: + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_payment_gateway_experiment"}, + "segment_overrides": [ + { + "segment_id": 101, + "enabled": true, + "value": {"type": "string", "value": "enterprise_gateway"}, + "variants": [ + { + "key": "new_gateway_a", + "weight": 0.25 + }, + { + "key": "new_gateway_b", + "weight": 0.25 + } + ] + } + ] + }' +``` + +### Remove a segment override + +A special `delete` attribute can be used to remove a segment override from a feature. It cannot be combined with other +attributes besides `segment_id`. + +```bash +curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ + -H 'Authorization: Api-Key ' \ + -H 'Content-Type: application/json' \ + -d '{ + "feature": {"name": "new_checkout"}, + "segment_overrides": [ + { + "segment_id": 456, + "delete": true + } + ] + }' +``` From 47f53c9d23064674e58f26dcf011712e18e46f72 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 24 Jul 2026 22:05:15 -0300 Subject: [PATCH 02/39] then the contract --- .../features/experiments/__init__.py | 0 .../experiments/test_update_flag_endpoint.py | 765 ++++++++++++++++++ 2 files changed, 765 insertions(+) create mode 100644 api/tests/integration/features/experiments/__init__.py create mode 100644 api/tests/integration/features/experiments/test_update_flag_endpoint.py diff --git a/api/tests/integration/features/experiments/__init__.py b/api/tests/integration/features/experiments/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/integration/features/experiments/test_update_flag_endpoint.py b/api/tests/integration/features/experiments/test_update_flag_endpoint.py new file mode 100644 index 000000000000..7db9f501898e --- /dev/null +++ b/api/tests/integration/features/experiments/test_update_flag_endpoint.py @@ -0,0 +1,765 @@ +"""https://docs.flagsmith.com/managing-flags/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 + + +@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.fixture() +def segment_2( + admin_client: APIClient, + project: int, +) -> int: + response = admin_client.post( + f"/api/v1/projects/{project}/segments/", + { + "name": "Test Segment 2", + "project": project, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + }, + format="json", + ) + return int(response.json()["id"]) + + +def test_update_flag__environment_default_enabled__toggles_flag( + admin_client: APIClient, + environment_api_key: str, + feature: int, + feature_name: str, + versioned_environment: Environment, +) -> None: + # Given + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"name": feature_name}, + "environment_default": {"enabled": True}, + }, + format="json", + ) + + # Then + assert response.status_code == 204 + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is True + + +def test_update_flag__environment_default_value__updates_value( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "integer", "value": "1000"}, + }, + }, + format="json", + ) + + # Then + assert response.status_code == 204 + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is True + assert environment_default.get_feature_state_value() == 1000 + + +@pytest.mark.parametrize( + "update", + [ + pytest.param({"value": {"type": "string", "value": "control"}}, id="enabled"), + pytest.param({"enabled": True}, id="value"), + pytest.param({}, id="enabled-and-value"), + ], +) +def test_update_flag__environment_default_attribute_omitted__left_unchanged( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, + update: dict[str, object], +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + }, + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": update, + }, + format="json", + ) + + # Then + assert response.status_code == 204 + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is True + assert environment_default.get_feature_state_value() == "control" + + +def test_update_flag__segment_overrides__creates_overrides( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": "standard"}, + }, + "segment_overrides": [ + { + "segment_id": segment, + "priority": 1, + "enabled": True, + "value": {"type": "string", "value": "enterprise"}, + }, + { + "segment_id": segment_2, + "priority": 2, + "enabled": True, + "value": {"type": "string", "value": "premium"}, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + environment_default = live_feature_states.get(feature_segment=None) + assert environment_default.enabled is False + assert environment_default.get_feature_state_value() == "standard" + enterprise_override = live_feature_states.get(feature_segment__segment_id=segment) + assert enterprise_override.enabled is True + assert enterprise_override.get_feature_state_value() == "enterprise" + premium_override = live_feature_states.get(feature_segment__segment_id=segment_2) + assert premium_override.enabled is True + assert premium_override.get_feature_state_value() == "premium" + + +def test_update_flag__environment_default_variants__creates_variants( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "default_gateway"}, + "variants": [ + { + "key": "new_gateway_a", + "weight": 0.1, + "value": {"type": "string", "value": "sharp_payments"}, + }, + { + "key": "new_gateway_b", + "weight": 0.1, + "value": {"type": "string", "value": "e_z_pay"}, + }, + ], + }, + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert dict( + MultivariateFeatureOption.objects.filter(feature_id=feature).values_list( + "key", "string_value" + ) + ) == {"new_gateway_a": "sharp_payments", "new_gateway_b": "e_z_pay"} + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"new_gateway_a": 10, "new_gateway_b": 10} + + +def test_update_flag__segment_override_variants__reweights_for_segment_only( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "default_gateway"}, + "variants": [ + { + "key": "new_gateway_a", + "weight": 0.1, + "value": {"type": "string", "value": "sharp_payments"}, + }, + { + "key": "new_gateway_b", + "weight": 0.1, + "value": {"type": "string", "value": "e_z_pay"}, + }, + ], + }, + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "enabled": True, + "value": {"type": "string", "value": "enterprise_gateway"}, + "variants": [ + {"key": "new_gateway_a", "weight": 0.25}, + {"key": "new_gateway_b", "weight": 0.25}, + ], + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + override = live_feature_states.get(feature_segment__segment_id=segment) + assert dict( + override.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"new_gateway_a": 25, "new_gateway_b": 25} + environment_default = live_feature_states.get(feature_segment=None) + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"new_gateway_a": 10, "new_gateway_b": 10} + + +def test_update_flag__segment_override_variant_omitted__keeps_weight_for_segment( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "default"}, + "variants": [ + { + "key": "variant_a", + "weight": 0.1, + "value": {"type": "string", "value": "a"}, + }, + { + "key": "variant_b", + "weight": 0.2, + "value": {"type": "string", "value": "b"}, + }, + ], + }, + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "enabled": True, + "value": {"type": "string", "value": "override"}, + "variants": [ + {"key": "variant_a", "weight": 0.3}, + ], + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ).get(feature_segment__segment_id=segment) + assert dict( + override.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_a": 30, "variant_b": 20} + + +def test_update_flag__environment_default_variant_omitted__deletes_variant( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "default"}, + "variants": [ + { + "key": "variant_kept", + "weight": 0.1, + "value": {"type": "string", "value": "kept"}, + }, + { + "key": "variant_deleted", + "weight": 0.2, + "value": {"type": "string", "value": "deleted"}, + }, + ], + }, + "segment_overrides": [ + { + "segment_id": segment, + "enabled": True, + "value": {"type": "string", "value": "override"}, + "variants": [ + {"key": "variant_kept", "weight": 0.3}, + {"key": "variant_deleted", "weight": 0.4}, + ], + }, + ], + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "variants": [ + {"key": "variant_kept", "weight": 0.5}, + ], + }, + }, + format="json", + ) + + # Then + assert response.status_code == 204 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + environment_default = live_feature_states.get(feature_segment=None) + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_kept": 50} + override = live_feature_states.get(feature_segment__segment_id=segment) + assert dict( + override.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_kept": 30} + + +def test_update_flag__segment_override_delete__removes_override( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "enabled": True, + "value": {"type": "string", "value": "override"}, + }, + ], + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + {"segment_id": segment, "delete": True}, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert ( + not FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .filter(feature_segment__segment_id=segment) + .exists() + ) + + +def test_update_flag__segment_override_delete_with_other_attributes__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + {"segment_id": segment, "delete": True, "enabled": True}, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "delete" in str(response.json()) + + +@pytest.mark.parametrize( + "update", + [ + pytest.param( + lambda segment: { + "environment_default": { + "variants": [ + {"key": "variant_a", "weight": 0.6}, + {"key": "variant_b", "weight": 0.5}, + ], + }, + }, + id="environment-default", + ), + pytest.param( + lambda segment: { + "segment_overrides": [ + { + "segment_id": segment, + "variants": [ + {"key": "variant_a", "weight": 0.6}, + {"key": "variant_b", "weight": 0.5}, + ], + }, + ], + }, + id="segment-override", + ), + ], +) +def test_update_flag__variant_weights_exceed_one__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, + update: Callable[[int], dict[str, Any]], +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "variants": [ + { + "key": "variant_a", + "weight": 0.1, + "value": {"type": "string", "value": "a"}, + }, + { + "key": "variant_b", + "weight": 0.1, + "value": {"type": "string", "value": "b"}, + }, + ], + }, + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + **update(segment), + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "variants" in str(response.json()) + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_a": 10, "variant_b": 10} + + +@pytest.mark.parametrize( + "variant", + [ + pytest.param( + { + "key": "variant_a", + "weight": 0.5, + "value": {"type": "string", "value": "changed"}, + }, + id="value-provided", + ), + pytest.param( + {"key": "unknown_variant", "weight": 0.5}, + id="unknown-key", + ), + ], +) +def test_update_flag__segment_override_variant_not_reweighting__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + variant: dict[str, Any], +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": { + "variants": [ + { + "key": "variant_a", + "weight": 0.1, + "value": {"type": "string", "value": "a"}, + }, + ], + }, + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "variants": [variant], + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "variants" in str(response.json()) + + +def test_update_flag__unknown_feature__responds_400( + admin_client: APIClient, + environment_api_key: str, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"name": "unknown_feature"}, + "environment_default": {"enabled": True}, + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "feature" in str(response.json()).lower() + + +def test_update_flag__unknown_segment__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + {"segment_id": 999999, "enabled": True}, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "segment" in str(response.json()).lower() + + +def test_update_flag__change_requests_enabled__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given + versioned_environment.minimum_change_request_approvals = 0 + versioned_environment.save() + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "environment_default": {"enabled": True}, + }, + format="json", + ) + + # Then + assert response.status_code == 400 + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False From 98593e00abd7caaae4e7852cd527c1b3a9abc468 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 3 Aug 2026 19:41:32 -0300 Subject: [PATCH 03/39] get priorities straight --- .../experiments/test_update_flag_endpoint.py | 108 +++++++++++++++++- docs/docs/managing-flags/updating-flags.md | 9 +- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/api/tests/integration/features/experiments/test_update_flag_endpoint.py b/api/tests/integration/features/experiments/test_update_flag_endpoint.py index 7db9f501898e..e9eefadd3625 100644 --- a/api/tests/integration/features/experiments/test_update_flag_endpoint.py +++ b/api/tests/integration/features/experiments/test_update_flag_endpoint.py @@ -174,13 +174,13 @@ def test_update_flag__segment_overrides__creates_overrides( "segment_overrides": [ { "segment_id": segment, - "priority": 1, + "priority": 10, "enabled": True, "value": {"type": "string", "value": "enterprise"}, }, { "segment_id": segment_2, - "priority": 2, + "priority": 20, "enabled": True, "value": {"type": "string", "value": "premium"}, }, @@ -199,13 +199,117 @@ def test_update_flag__segment_overrides__creates_overrides( assert environment_default.enabled is False assert environment_default.get_feature_state_value() == "standard" enterprise_override = live_feature_states.get(feature_segment__segment_id=segment) + assert enterprise_override.priority == 10 assert enterprise_override.enabled is True assert enterprise_override.get_feature_state_value() == "enterprise" premium_override = live_feature_states.get(feature_segment__segment_id=segment_2) + assert premium_override.priority == 20 assert premium_override.enabled is True assert premium_override.get_feature_state_value() == "premium" +def test_update_flag__segment_override_priority_omitted__sets_priority_from_list_position( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "priority": 10, + "enabled": True, + "value": {"type": "string", "value": "enterprise"}, + }, + { + "segment_id": segment_2, + "enabled": True, + "value": {"type": "string", "value": "premium"}, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert live_feature_states.get(feature_segment__segment_id=segment).priority == 10 + assert live_feature_states.get(feature_segment__segment_id=segment_2).priority == 1 + + +@pytest.mark.parametrize( + "update", + [ + pytest.param( + {"priority": 10, "value": {"type": "string", "value": "enterprise"}}, + id="enabled", + ), + pytest.param({"priority": 10, "enabled": True}, id="value"), + pytest.param( + {"enabled": True, "value": {"type": "string", "value": "enterprise"}}, + id="priority", + ), + pytest.param({}, id="enabled-and-value-and-priority"), + ], +) +def test_update_flag__segment_override_attribute_omitted__left_unchanged( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, + update: dict[str, object], +) -> None: + # Given + setup_response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [ + { + "segment_id": segment, + "priority": 10, + "enabled": True, + "value": {"type": "string", "value": "enterprise"}, + }, + ], + }, + format="json", + ) + assert setup_response.status_code == 204 + + # When + response = admin_client.post( + f"/api/experiments/environments/{environment_api_key}/update-flag/", + { + "feature": {"id": feature}, + "segment_overrides": [{"segment_id": segment, **update}], + }, + format="json", + ) + + # Then + assert response.status_code == 204 + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ).get(feature_segment__segment_id=segment) + assert override.priority == 10 + assert override.enabled is True + assert override.get_feature_state_value() == "enterprise" + + def test_update_flag__environment_default_variants__creates_variants( admin_client: APIClient, environment_api_key: str, diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 885ba41efc8d..400ddc56a219 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -90,9 +90,6 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen }' ``` -The `priority` field in segment overrides is optional. The lowest number has the highest priority. Omit it to add at the -lowest priority. - ### Configure multiple segment overrides Set different values per segment — for example, pricing tiers. @@ -110,13 +107,13 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen "segment_overrides": [ { "segment_id": 101, - "priority": 1, + "priority": 10, "enabled": true, "value": {"type": "string", "value": "enterprise"} }, { "segment_id": 202, - "priority": 2, + "priority": 20, "enabled": true, "value": {"type": "string", "value": "premium"} } @@ -124,6 +121,8 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen }' ``` +When adding a new segment override, and `priority` is omitted, priority is set to the position of the override in the `segment_overrides` list. The lowest number has the highest priority. + ### Configure A/B/n experiments (variants) Set up features with weighted variants and customise weights per segment. From e111394619c19490937c31d9dc878e4783c5b0ea Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 3 Aug 2026 21:21:21 -0300 Subject: [PATCH 04/39] tried so hard, got so far --- .../identities/test_integration_identities.py | 18 +- .../experiments/test_update_flag_endpoint.py | 403 ++++-------------- api/tests/integration/helpers.py | 2 +- docs/docs/managing-flags/updating-flags.md | 39 +- 4 files changed, 108 insertions(+), 354 deletions(-) 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/features/experiments/test_update_flag_endpoint.py b/api/tests/integration/features/experiments/test_update_flag_endpoint.py index e9eefadd3625..27e860338095 100644 --- a/api/tests/integration/features/experiments/test_update_flag_endpoint.py +++ b/api/tests/integration/features/experiments/test_update_flag_endpoint.py @@ -1,15 +1,12 @@ """https://docs.flagsmith.com/managing-flags/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 @pytest.fixture(params=["feature_versioning_v1", "feature_versioning_v2"], autouse=True) @@ -39,6 +36,26 @@ def segment_2( return int(response.json()["id"]) +@pytest.fixture() +def feature_variants( + admin_client: APIClient, + project: int, + feature: int, +) -> None: + for key, value, default_percentage_allocation in [ + ("variant_a", "a", 10), + ("variant_b", "b", 20), + ]: + create_mv_option_with_api( + admin_client, + project, + feature, + default_percentage_allocation, + value, + key=key, + ) + + def test_update_flag__environment_default_enabled__toggles_flag( admin_client: APIClient, environment_api_key: str, @@ -310,56 +327,64 @@ def test_update_flag__segment_override_attribute_omitted__left_unchanged( assert override.get_feature_state_value() == "enterprise" -def test_update_flag__environment_default_variants__creates_variants( +@pytest.mark.parametrize( + "variants, expected_allocations", + [ + pytest.param( + [ + {"key": "variant_a", "weight": 0.25}, + {"key": "variant_b", "weight": 0.25}, + ], + {"variant_a": 25, "variant_b": 25}, + id="fractional", + ), + pytest.param( + [ + {"key": "variant_a", "weight": 0.5}, + {"key": "variant_b", "weight": 0}, + ], + {"variant_a": 50, "variant_b": 0}, + id="zero-weight", + ), + ], +) +@pytest.mark.usefixtures("feature_variants") +def test_update_flag__environment_default_variants__reweights_variants( admin_client: APIClient, environment_api_key: str, feature: int, versioned_environment: Environment, + variants: list[dict[str, object]], + expected_allocations: dict[str, float], ) -> None: # Given / When response = admin_client.post( f"/api/experiments/environments/{environment_api_key}/update-flag/", { "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "string", "value": "default_gateway"}, - "variants": [ - { - "key": "new_gateway_a", - "weight": 0.1, - "value": {"type": "string", "value": "sharp_payments"}, - }, - { - "key": "new_gateway_b", - "weight": 0.1, - "value": {"type": "string", "value": "e_z_pay"}, - }, - ], - }, + "environment_default": {"variants": variants}, }, format="json", ) # Then assert response.status_code == 204 - assert dict( - MultivariateFeatureOption.objects.filter(feature_id=feature).values_list( - "key", "string_value" - ) - ) == {"new_gateway_a": "sharp_payments", "new_gateway_b": "e_z_pay"} environment_default = FeatureState.objects.get_live_feature_states( environment=versioned_environment, feature_id=feature, feature_segment=None, ).get() - assert dict( - environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" + assert ( + dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) ) - ) == {"new_gateway_a": 10, "new_gateway_b": 10} + == expected_allocations + ) +@pytest.mark.usefixtures("feature_variants") def test_update_flag__segment_override_variants__reweights_for_segment_only( admin_client: APIClient, environment_api_key: str, @@ -367,33 +392,7 @@ def test_update_flag__segment_override_variants__reweights_for_segment_only( segment: int, versioned_environment: Environment, ) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "string", "value": "default_gateway"}, - "variants": [ - { - "key": "new_gateway_a", - "weight": 0.1, - "value": {"type": "string", "value": "sharp_payments"}, - }, - { - "key": "new_gateway_b", - "weight": 0.1, - "value": {"type": "string", "value": "e_z_pay"}, - }, - ], - }, - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When + # Given / When response = admin_client.post( f"/api/experiments/environments/{environment_api_key}/update-flag/", { @@ -402,10 +401,9 @@ def test_update_flag__segment_override_variants__reweights_for_segment_only( { "segment_id": segment, "enabled": True, - "value": {"type": "string", "value": "enterprise_gateway"}, "variants": [ - {"key": "new_gateway_a", "weight": 0.25}, - {"key": "new_gateway_b", "weight": 0.25}, + {"key": "variant_a", "weight": 0.25}, + {"key": "variant_b", "weight": 0.25}, ], }, ], @@ -424,156 +422,13 @@ def test_update_flag__segment_override_variants__reweights_for_segment_only( override.multivariate_feature_state_values.values_list( "multivariate_feature_option__key", "percentage_allocation" ) - ) == {"new_gateway_a": 25, "new_gateway_b": 25} + ) == {"variant_a": 25, "variant_b": 25} environment_default = live_feature_states.get(feature_segment=None) assert dict( environment_default.multivariate_feature_state_values.values_list( "multivariate_feature_option__key", "percentage_allocation" ) - ) == {"new_gateway_a": 10, "new_gateway_b": 10} - - -def test_update_flag__segment_override_variant_omitted__keeps_weight_for_segment( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "string", "value": "default"}, - "variants": [ - { - "key": "variant_a", - "weight": 0.1, - "value": {"type": "string", "value": "a"}, - }, - { - "key": "variant_b", - "weight": 0.2, - "value": {"type": "string", "value": "b"}, - }, - ], - }, - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "enabled": True, - "value": {"type": "string", "value": "override"}, - "variants": [ - {"key": "variant_a", "weight": 0.3}, - ], - }, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - override = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ).get(feature_segment__segment_id=segment) - assert dict( - override.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_a": 30, "variant_b": 20} - - -def test_update_flag__environment_default_variant_omitted__deletes_variant( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "string", "value": "default"}, - "variants": [ - { - "key": "variant_kept", - "weight": 0.1, - "value": {"type": "string", "value": "kept"}, - }, - { - "key": "variant_deleted", - "weight": 0.2, - "value": {"type": "string", "value": "deleted"}, - }, - ], - }, - "segment_overrides": [ - { - "segment_id": segment, - "enabled": True, - "value": {"type": "string", "value": "override"}, - "variants": [ - {"key": "variant_kept", "weight": 0.3}, - {"key": "variant_deleted", "weight": 0.4}, - ], - }, - ], - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "variants": [ - {"key": "variant_kept", "weight": 0.5}, - ], - }, - }, - format="json", - ) - - # Then - assert response.status_code == 204 - live_feature_states = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ) - environment_default = live_feature_states.get(feature_segment=None) - assert dict( - environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_kept": 50} - override = live_feature_states.get(feature_segment__segment_id=segment) - assert dict( - override.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_kept": 30} + ) == {"variant_a": 10, "variant_b": 20} def test_update_flag__segment_override_delete__removes_override( @@ -647,74 +502,53 @@ def test_update_flag__segment_override_delete_with_other_attributes__responds_40 assert "delete" in str(response.json()) +@pytest.mark.parametrize("context", ["environment_default", "segment_overrides"]) @pytest.mark.parametrize( - "update", + "variants", [ pytest.param( - lambda segment: { - "environment_default": { - "variants": [ - {"key": "variant_a", "weight": 0.6}, - {"key": "variant_b", "weight": 0.5}, - ], - }, - }, - id="environment-default", + [ + {"key": "variant_a", "weight": 0.6}, + {"key": "variant_b", "weight": 0.5}, + ], + id="weights-exceed-one", ), pytest.param( - lambda segment: { - "segment_overrides": [ - { - "segment_id": segment, - "variants": [ - {"key": "variant_a", "weight": 0.6}, - {"key": "variant_b", "weight": 0.5}, - ], - }, - ], - }, - id="segment-override", + [{"key": "variant_a", "weight": 0.5}], + id="variant-omitted", + ), + pytest.param( + [ + {"key": "variant_a", "weight": 0.1}, + {"key": "variant_b", "weight": 0.2}, + {"key": "unknown_variant", "weight": 0.5}, + ], + id="unknown-key", ), ], ) -def test_update_flag__variant_weights_exceed_one__responds_400( +@pytest.mark.usefixtures("feature_variants") +def test_update_flag__invalid_variants__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, segment: int, versioned_environment: Environment, - update: Callable[[int], dict[str, Any]], + context: str, + variants: list[dict[str, object]], ) -> None: # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "variants": [ - { - "key": "variant_a", - "weight": 0.1, - "value": {"type": "string", "value": "a"}, - }, - { - "key": "variant_b", - "weight": 0.1, - "value": {"type": "string", "value": "b"}, - }, - ], - }, - }, - format="json", - ) - assert setup_response.status_code == 204 + update = { + "environment_default": {"variants": variants}, + "segment_overrides": [{"segment_id": segment, "variants": variants}], + }[context] # When response = admin_client.post( f"/api/experiments/environments/{environment_api_key}/update-flag/", { "feature": {"id": feature}, - **update(segment), + context: update, }, format="json", ) @@ -731,70 +565,7 @@ def test_update_flag__variant_weights_exceed_one__responds_400( environment_default.multivariate_feature_state_values.values_list( "multivariate_feature_option__key", "percentage_allocation" ) - ) == {"variant_a": 10, "variant_b": 10} - - -@pytest.mark.parametrize( - "variant", - [ - pytest.param( - { - "key": "variant_a", - "weight": 0.5, - "value": {"type": "string", "value": "changed"}, - }, - id="value-provided", - ), - pytest.param( - {"key": "unknown_variant", "weight": 0.5}, - id="unknown-key", - ), - ], -) -def test_update_flag__segment_override_variant_not_reweighting__responds_400( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - variant: dict[str, Any], -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "variants": [ - { - "key": "variant_a", - "weight": 0.1, - "value": {"type": "string", "value": "a"}, - }, - ], - }, - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "variants": [variant], - }, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 400 - assert "variants" in str(response.json()) + ) == {"variant_a": 10, "variant_b": 20} def test_update_flag__unknown_feature__responds_400( 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/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 400ddc56a219..8681c92b7f36 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -121,20 +121,18 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen }' ``` -When adding a new segment override, and `priority` is omitted, priority is set to the position of the override in the `segment_overrides` list. The lowest number has the highest priority. +When adding a new segment override, and `priority` is omitted, priority is set to the position of the override in the +`segment_overrides` list. The lowest number has the highest priority. -### Configure A/B/n experiments (variants) +### Re-weight experiment variants (A/B/n) -Set up features with weighted variants and customise weights per segment. - -The `variants` list in `environment_default` defines the available variants for the feature, and their default weights -in the environment. Each variant is identified by a `key` (slug format). Omitting a variant from the list deletes it in -the environment, and in any segment overrides. +On previously-configured experiments (multivariate features), the weight of each variant can be adjusted in the +environment and per segment with the `variants` property. A `weight` is a fraction between 0 and 1. Any weight not allocated to variants serves the flag's default `value`. -The `variants` list in `segment_overrides` can only re-weight existing variants. Variants omitted from it keep their -current weights for that segment. +In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the +feature, even if their weight is 0. ```bash curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ @@ -146,16 +144,8 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen "enabled": true, "value": {"type": "string", "value": "default_gateway"}, "variants": [ - { - "key": "new_gateway_a", - "weight": 0.1, - "value": {"type": "string", "value": "sharp_payments"} - }, - { - "key": "new_gateway_b", - "weight": 0.1, - "value": {"type": "string", "value": "e_z_pay"} - } + {"key": "variant_a", "weight": 0.1}, + {"key": "variant_b", "weight": 0.1} ] } }' @@ -173,16 +163,9 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen { "segment_id": 101, "enabled": true, - "value": {"type": "string", "value": "enterprise_gateway"}, "variants": [ - { - "key": "new_gateway_a", - "weight": 0.25 - }, - { - "key": "new_gateway_b", - "weight": 0.25 - } + {"key": "variant_a", "weight": 0.25}, + {"key": "variant_b", "weight": 0.25} ] } ] From 51740b87e1646e3bb9db642e618b528f69ab1f33 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 5 Aug 2026 12:15:37 -0300 Subject: [PATCH 05/39] percentage rules --- docs/docs/managing-flags/updating-flags.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 8681c92b7f36..66e22b705666 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -129,10 +129,10 @@ When adding a new segment override, and `priority` is omitted, priority is set t On previously-configured experiments (multivariate features), the weight of each variant can be adjusted in the environment and per segment with the `variants` property. -A `weight` is a fraction between 0 and 1. Any weight not allocated to variants serves the flag's default `value`. +A `weight` is a percentage between 0 and 100. Any weight not allocated to variants serves the flag's default `value`. In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the -feature, even if their weight is 0. +feature, even if their weight is zero. ```bash curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ @@ -144,8 +144,8 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen "enabled": true, "value": {"type": "string", "value": "default_gateway"}, "variants": [ - {"key": "variant_a", "weight": 0.1}, - {"key": "variant_b", "weight": 0.1} + {"key": "variant_a", "weight": 10}, + {"key": "variant_b", "weight": 10.5} ] } }' @@ -164,8 +164,8 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen "segment_id": 101, "enabled": true, "variants": [ - {"key": "variant_a", "weight": 0.25}, - {"key": "variant_b", "weight": 0.25} + {"key": "variant_a", "weight": 25}, + {"key": "variant_b", "weight": 25} ] } ] From 4b2328aaccf0777ff6e553a1482312de88802c9b Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 5 Aug 2026 12:21:11 -0300 Subject: [PATCH 06/39] dang it Venn --- docs/docs/managing-flags/updating-flags.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 66e22b705666..1389f6f725df 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -124,9 +124,9 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen When adding a new segment override, and `priority` is omitted, priority is set to the position of the override in the `segment_overrides` list. The lowest number has the highest priority. -### Re-weight experiment variants (A/B/n) +### Re-weight variants (A/B/n) -On previously-configured experiments (multivariate features), the weight of each variant can be adjusted in the +On previously-configured multivariate features (e.g. experiments), the weight of each variant can be adjusted in the environment and per segment with the `variants` property. A `weight` is a percentage between 0 and 100. Any weight not allocated to variants serves the flag's default `value`. From cfe48faacb858e5b3872fa2df949ac3c1b890b00 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 5 Aug 2026 12:33:48 -0300 Subject: [PATCH 07/39] =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E3=82=92=E8=A6=8B?= =?UTF-8?q?=E3=81=A6=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/managing-flags/updating-flags.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 1389f6f725df..c487d07d172b 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -4,7 +4,7 @@ sidebar_label: Updating Flags (Experimental) sidebar_position: 6 --- -The `/api/experiments/environments/{environment_key}/update-flag/` endpoint lets you update feature flags, segment +The `/api/__future__/environments/{environment_key}/update-flag/` endpoint lets you update feature flags, segment overrides, and variant allocations via the Admin API. A successful response is always **204 No Content**. The request body is a JSON object with the following fields: @@ -24,7 +24,7 @@ Values are passed as a `value` object with `type` and `value` (always a string): | `boolean` | `{"type": "boolean", "value": "true"}` | Learn more in the -[API specification](https://api.flagsmith.com/api/v1/docs/#/experimental/api_experiments_environments_update_flag). +[API specification](https://api.flagsmith.com/api/v1/docs/#/experimental/api___future___environments_update_flag_create). :::caution @@ -40,7 +40,7 @@ Learn more in the The simplest case — flip a feature flag in an environment. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -54,7 +54,7 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen Change a feature's value — for example, setting a rate limit. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -71,7 +71,7 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen Enable a feature for a specific segment (e.g. beta users) while keeping it off for everyone else. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -95,7 +95,7 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen Set different values per segment — for example, pricing tiers. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -135,7 +135,7 @@ In both `environment_default` and `segment_overrides`, the `variants` list **mus feature, even if their weight is zero. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -154,7 +154,7 @@ curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environmen Within the same request as above, or separately, you can also set different weights for a segment: ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ @@ -178,7 +178,7 @@ A special `delete` attribute can be used to remove a segment override from a fea attributes besides `segment_id`. ```bash -curl -X POST 'https://api.flagsmith.com/api/experiments/environments/{environment_key}/update-flag/' \ +curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ -H 'Authorization: Api-Key ' \ -H 'Content-Type: application/json' \ -d '{ From 81077d9eab8eb471396fc038d2d1ee7b4bb55254 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 5 Aug 2026 19:03:18 -0300 Subject: [PATCH 08/39] now i see the full picture --- docs/docs/managing-flags/updating-flags.md | 168 ++++++++++----------- 1 file changed, 76 insertions(+), 92 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index c487d07d172b..cc83d8a8dc1f 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -1,66 +1,64 @@ --- -title: Updating Flags (Experimental) -sidebar_label: Updating Flags (Experimental) -sidebar_position: 6 +title: 'Experimental: Updating Flags' +sidebar_label: 'Experimental: Updating Flags' --- -The `/api/__future__/environments/{environment_key}/update-flag/` endpoint lets you update feature flags, segment -overrides, and variant allocations via the Admin API. +We're experimenting with a set of new endpoints for updating feature flags. They should provide better ergonomics for +the most common use cases, while keeping operations agnostic to +[Feature Versioning](/managing-flags/feature-versioning). We plan to dogfood them in our own dashboard and CLI, and +eventually make them canonical. -A successful response is always **204 No Content**. The request body is a JSON object with the following fields: +:::caution -- `feature` (required) — the feature to update, identified by `name` or `id`. -- `environment_default` (optional) — the default state of the feature in the environment. -- `segment_overrides` (optional) — a list of segment overrides for the feature. +**These endpoints are experimental and may change without notice.** Note these limitations: -Any attribute omitted in the payload will be left unchanged. +- They cannot be used when [change requests](/administration-and-security/governance-and-compliance/change-requests) are + enabled. +- They do not support identity overrides. -Values are passed as a `value` object with `type` and `value` (always a string): +These may be lifted in the future. -| Type | Example | -| --------- | -------------------------------------- | -| `string` | `{"type": "string", "value": "hello"}` | -| `integer` | `{"type": "integer", "value": "42"}` | -| `boolean` | `{"type": "boolean", "value": "true"}` | +::: -Learn more in the -[API specification](https://api.flagsmith.com/api/v1/docs/#/experimental/api___future___environments_update_flag_create). +Learn more in the [API specification](link TODO). -:::caution +## Updating a flag -**This endpoint is experimental and may change without notice.** It cannot be used when -[change requests](/administration-and-security/governance-and-compliance/change-requests) are enabled. +We support both `PATCH` and `PUT` methods for updating a flag. Both accept optional `environment_default` and +`segment_overrides` properties. Attributes omitted from a `PATCH` payload are left unchanged, while `PUT` replaces +each property it receives in full — use it with caution. -::: +Values are passed as a `value` object with a `type` and a `value` string: -## Examples +| Type | Example | +| --------- | -------------------------------------- | +| `string` | `{"type": "string", "value": "hello"}` | +| `integer` | `{"type": "integer", "value": "42"}` | +| `boolean` | `{"type": "boolean", "value": "true"}` | ### Toggle a flag on or off -The simplest case — flip a feature flag in an environment. +The simplest case — flip a feature flag in an environment: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "maintenance_mode"}, "environment_default": {"enabled": true} }' ``` ### Update a feature value -Change a feature's value — for example, setting a rate limit. +Change a feature's default value in an environment: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "api_rate_limit"}, "environment_default": { - "enabled": true, "value": {"type": "integer", "value": "1000"} } }' @@ -68,81 +66,86 @@ curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment ### Roll out a feature to a segment -Enable a feature for a specific segment (e.g. beta users) while keeping it off for everyone else. +Enable a flag for one or more segments, while keeping it off for everyone else: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "new_checkout"}, "environment_default": { - "enabled": false, - "value": {"type": "boolean", "value": "false"} + "enabled": false }, "segment_overrides": [ { - "segment_id": 456, + "segment": {"id": 101}, "enabled": true, - "value": {"type": "boolean", "value": "true"} + "priority": 10 + }, + { + "segment": {"id": 202}, + "enabled": true, + "priority": 20 } ] }' ``` -### Configure multiple segment overrides - -Set different values per segment — for example, pricing tiers. +Segments can also override the feature's value for the environment: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "pricing_tier"}, - "environment_default": { - "enabled": true, - "value": {"type": "string", "value": "standard"} - }, "segment_overrides": [ { - "segment_id": 101, - "priority": 10, - "enabled": true, + "segment": {"id": 101}, "value": {"type": "string", "value": "enterprise"} - }, - { - "segment_id": 202, - "priority": 20, - "enabled": true, - "value": {"type": "string", "value": "premium"} } ] }' ``` -When adding a new segment override, and `priority` is omitted, priority is set to the position of the override in the +Overrides listed in a `PATCH` payload are added or updated by segment; overrides not listed are left unchanged. When +adding a new segment override, if `priority` is omitted, it defaults to the override's position in the `segment_overrides` list. The lowest number has the highest priority. +### Remove a segment override + +To remove a segment override, `PUT` the full list of overrides without it. `PUT` replaces the whole set, deleting any +override not listed: + +```bash +curl -X PUT 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ + -H 'Content-Type: application/json' \ + -d '{ + "segment_overrides": [ + { + "segment": {"id": 101}, + "priority": 10, + "value": {"type": "string", "value": "enterprise"} + } + ] + }' +``` + ### Re-weight variants (A/B/n) -On previously-configured multivariate features (e.g. experiments), the weight of each variant can be adjusted in the +On previously configured multivariate features (e.g. experiments), the weight of each variant can be adjusted in the environment and per segment with the `variants` property. A `weight` is a percentage between 0 and 100. Any weight not allocated to variants serves the flag's default `value`. -In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the -feature, even if their weight is zero. +Re-weight the variants for a feature in the environment: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "new_payment_gateway_experiment"}, "environment_default": { - "enabled": true, - "value": {"type": "string", "value": "default_gateway"}, "variants": [ {"key": "variant_a", "weight": 10}, {"key": "variant_b", "weight": 10.5} @@ -154,15 +157,13 @@ curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment Within the same request as above, or separately, you can also set different weights for a segment: ```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ +curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' \ -H 'Content-Type: application/json' \ -d '{ - "feature": {"name": "new_payment_gateway_experiment"}, "segment_overrides": [ { - "segment_id": 101, - "enabled": true, + "segment": {"id": 101}, "variants": [ {"key": "variant_a", "weight": 25}, {"key": "variant_b", "weight": 25} @@ -172,22 +173,5 @@ curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment }' ``` -### Remove a segment override - -A special `delete` attribute can be used to remove a segment override from a feature. It cannot be combined with other -attributes besides `segment_id`. - -```bash -curl -X POST 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/update-flag/' \ - -H 'Authorization: Api-Key ' \ - -H 'Content-Type: application/json' \ - -d '{ - "feature": {"name": "new_checkout"}, - "segment_overrides": [ - { - "segment_id": 456, - "delete": true - } - ] - }' -``` +In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the +feature, even if their weight is zero. From b9649096c853c90e4e0d569c951804146209076b Mon Sep 17 00:00:00 2001 From: Evandro Myller <22429+emyller@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:14:47 -0300 Subject: [PATCH 09/39] =?UTF-8?q?=F0=9F=9A=AB=F0=9F=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matthew Elwell --- docs/docs/managing-flags/updating-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index cc83d8a8dc1f..596da6d7dc2a 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -38,7 +38,7 @@ Values are passed as a `value` object with a `type` and a `value` string: ### Toggle a flag on or off -The simplest case — flip a feature flag in an environment: +The simplest case — enable a feature flag in an environment: ```bash curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ From c1ebb0e1a862aeb71c9c0025a1ee64f908b292e6 Mon Sep 17 00:00:00 2001 From: Evandro Myller <22429+emyller@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:15:42 -0300 Subject: [PATCH 10/39] clarity Co-authored-by: Matthew Elwell --- docs/docs/managing-flags/updating-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 596da6d7dc2a..bb83edd27e4e 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -174,4 +174,4 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen ``` In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the -feature, even if their weight is zero. +feature, even if their weight is zero, and regardless of the HTTP verb used (`PUT` or `PATCH`). From 4889df12414530b8ac421b687abb4447f688d6e1 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 13 Aug 2026 16:25:11 -0300 Subject: [PATCH 11/39] who needs tests --- .../identities/test_integration_identities.py | 18 +- .../features/experiments/__init__.py | 0 .../experiments/test_update_flag_endpoint.py | 640 ------------------ api/tests/integration/helpers.py | 2 +- 4 files changed, 10 insertions(+), 650 deletions(-) delete mode 100644 api/tests/integration/features/experiments/__init__.py delete mode 100644 api/tests/integration/features/experiments/test_update_flag_endpoint.py diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index 5be2e646b900..58e865ba3dbb 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, + multivariate_feature_id, # type: ignore[arg-type] variant_1_percentage_allocation, variant_1_value, ) variant_2_mvfo_id = create_mv_option_with_api( admin_client, project, - multivariate_feature_id, + multivariate_feature_id, # type: ignore[arg-type] 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, + multivariate_feature_id, # type: ignore[arg-type] 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, + multivariate_feature_id, # type: ignore[arg-type] 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, + multivariate_feature_id, # type: ignore[arg-type] 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, + feature_id, # type: ignore[arg-type] variant_1_percentage_allocation, variant_1_value, ) create_mv_option_with_api( admin_client, project, - feature_id, + feature_id, # type: ignore[arg-type] 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, + feature_id, # type: ignore[arg-type] variant_1_percentage_allocation, variant_1_value, ) create_mv_option_with_api( admin_client, project, - feature_id, + feature_id, # type: ignore[arg-type] variant_2_percentage_allocation, variant_2_value, ) diff --git a/api/tests/integration/features/experiments/__init__.py b/api/tests/integration/features/experiments/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/api/tests/integration/features/experiments/test_update_flag_endpoint.py b/api/tests/integration/features/experiments/test_update_flag_endpoint.py deleted file mode 100644 index 27e860338095..000000000000 --- a/api/tests/integration/features/experiments/test_update_flag_endpoint.py +++ /dev/null @@ -1,640 +0,0 @@ -"""https://docs.flagsmith.com/managing-flags/updating-flags""" - -import pytest -from rest_framework.test import APIClient - -from environments.models import Environment -from features.models import FeatureState -from features.versioning.tasks import enable_v2_versioning -from tests.integration.helpers import create_mv_option_with_api - - -@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.fixture() -def segment_2( - admin_client: APIClient, - project: int, -) -> int: - response = admin_client.post( - f"/api/v1/projects/{project}/segments/", - { - "name": "Test Segment 2", - "project": project, - "rules": [{"type": "ALL", "rules": [], "conditions": []}], - }, - format="json", - ) - return int(response.json()["id"]) - - -@pytest.fixture() -def feature_variants( - admin_client: APIClient, - project: int, - feature: int, -) -> None: - for key, value, default_percentage_allocation in [ - ("variant_a", "a", 10), - ("variant_b", "b", 20), - ]: - create_mv_option_with_api( - admin_client, - project, - feature, - default_percentage_allocation, - value, - key=key, - ) - - -def test_update_flag__environment_default_enabled__toggles_flag( - admin_client: APIClient, - environment_api_key: str, - feature: int, - feature_name: str, - versioned_environment: Environment, -) -> None: - # Given - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert environment_default.enabled is False - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"name": feature_name}, - "environment_default": {"enabled": True}, - }, - format="json", - ) - - # Then - assert response.status_code == 204 - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert environment_default.enabled is True - - -def test_update_flag__environment_default_value__updates_value( - admin_client: APIClient, - environment_api_key: str, - feature: int, - versioned_environment: Environment, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "integer", "value": "1000"}, - }, - }, - format="json", - ) - - # Then - assert response.status_code == 204 - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert environment_default.enabled is True - assert environment_default.get_feature_state_value() == 1000 - - -@pytest.mark.parametrize( - "update", - [ - pytest.param({"value": {"type": "string", "value": "control"}}, id="enabled"), - pytest.param({"enabled": True}, id="value"), - pytest.param({}, id="enabled-and-value"), - ], -) -def test_update_flag__environment_default_attribute_omitted__left_unchanged( - admin_client: APIClient, - environment_api_key: str, - feature: int, - versioned_environment: Environment, - update: dict[str, object], -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": True, - "value": {"type": "string", "value": "control"}, - }, - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": update, - }, - format="json", - ) - - # Then - assert response.status_code == 204 - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert environment_default.enabled is True - assert environment_default.get_feature_state_value() == "control" - - -def test_update_flag__segment_overrides__creates_overrides( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - segment_2: int, - versioned_environment: Environment, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": { - "enabled": False, - "value": {"type": "string", "value": "standard"}, - }, - "segment_overrides": [ - { - "segment_id": segment, - "priority": 10, - "enabled": True, - "value": {"type": "string", "value": "enterprise"}, - }, - { - "segment_id": segment_2, - "priority": 20, - "enabled": True, - "value": {"type": "string", "value": "premium"}, - }, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - live_feature_states = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ) - environment_default = live_feature_states.get(feature_segment=None) - assert environment_default.enabled is False - assert environment_default.get_feature_state_value() == "standard" - enterprise_override = live_feature_states.get(feature_segment__segment_id=segment) - assert enterprise_override.priority == 10 - assert enterprise_override.enabled is True - assert enterprise_override.get_feature_state_value() == "enterprise" - premium_override = live_feature_states.get(feature_segment__segment_id=segment_2) - assert premium_override.priority == 20 - assert premium_override.enabled is True - assert premium_override.get_feature_state_value() == "premium" - - -def test_update_flag__segment_override_priority_omitted__sets_priority_from_list_position( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - segment_2: int, - versioned_environment: Environment, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "priority": 10, - "enabled": True, - "value": {"type": "string", "value": "enterprise"}, - }, - { - "segment_id": segment_2, - "enabled": True, - "value": {"type": "string", "value": "premium"}, - }, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - live_feature_states = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ) - assert live_feature_states.get(feature_segment__segment_id=segment).priority == 10 - assert live_feature_states.get(feature_segment__segment_id=segment_2).priority == 1 - - -@pytest.mark.parametrize( - "update", - [ - pytest.param( - {"priority": 10, "value": {"type": "string", "value": "enterprise"}}, - id="enabled", - ), - pytest.param({"priority": 10, "enabled": True}, id="value"), - pytest.param( - {"enabled": True, "value": {"type": "string", "value": "enterprise"}}, - id="priority", - ), - pytest.param({}, id="enabled-and-value-and-priority"), - ], -) -def test_update_flag__segment_override_attribute_omitted__left_unchanged( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, - update: dict[str, object], -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "priority": 10, - "enabled": True, - "value": {"type": "string", "value": "enterprise"}, - }, - ], - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [{"segment_id": segment, **update}], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - override = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ).get(feature_segment__segment_id=segment) - assert override.priority == 10 - assert override.enabled is True - assert override.get_feature_state_value() == "enterprise" - - -@pytest.mark.parametrize( - "variants, expected_allocations", - [ - pytest.param( - [ - {"key": "variant_a", "weight": 0.25}, - {"key": "variant_b", "weight": 0.25}, - ], - {"variant_a": 25, "variant_b": 25}, - id="fractional", - ), - pytest.param( - [ - {"key": "variant_a", "weight": 0.5}, - {"key": "variant_b", "weight": 0}, - ], - {"variant_a": 50, "variant_b": 0}, - id="zero-weight", - ), - ], -) -@pytest.mark.usefixtures("feature_variants") -def test_update_flag__environment_default_variants__reweights_variants( - admin_client: APIClient, - environment_api_key: str, - feature: int, - versioned_environment: Environment, - variants: list[dict[str, object]], - expected_allocations: dict[str, float], -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": {"variants": variants}, - }, - format="json", - ) - - # Then - assert response.status_code == 204 - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert ( - dict( - environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) - == expected_allocations - ) - - -@pytest.mark.usefixtures("feature_variants") -def test_update_flag__segment_override_variants__reweights_for_segment_only( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "enabled": True, - "variants": [ - {"key": "variant_a", "weight": 0.25}, - {"key": "variant_b", "weight": 0.25}, - ], - }, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - live_feature_states = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ) - override = live_feature_states.get(feature_segment__segment_id=segment) - assert dict( - override.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_a": 25, "variant_b": 25} - environment_default = live_feature_states.get(feature_segment=None) - assert dict( - environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_a": 10, "variant_b": 20} - - -def test_update_flag__segment_override_delete__removes_override( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, -) -> None: - # Given - setup_response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - { - "segment_id": segment, - "enabled": True, - "value": {"type": "string", "value": "override"}, - }, - ], - }, - format="json", - ) - assert setup_response.status_code == 204 - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - {"segment_id": segment, "delete": True}, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 204 - assert ( - not FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - ) - .filter(feature_segment__segment_id=segment) - .exists() - ) - - -def test_update_flag__segment_override_delete_with_other_attributes__responds_400( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - {"segment_id": segment, "delete": True, "enabled": True}, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 400 - assert "delete" in str(response.json()) - - -@pytest.mark.parametrize("context", ["environment_default", "segment_overrides"]) -@pytest.mark.parametrize( - "variants", - [ - pytest.param( - [ - {"key": "variant_a", "weight": 0.6}, - {"key": "variant_b", "weight": 0.5}, - ], - id="weights-exceed-one", - ), - pytest.param( - [{"key": "variant_a", "weight": 0.5}], - id="variant-omitted", - ), - pytest.param( - [ - {"key": "variant_a", "weight": 0.1}, - {"key": "variant_b", "weight": 0.2}, - {"key": "unknown_variant", "weight": 0.5}, - ], - id="unknown-key", - ), - ], -) -@pytest.mark.usefixtures("feature_variants") -def test_update_flag__invalid_variants__responds_400( - admin_client: APIClient, - environment_api_key: str, - feature: int, - segment: int, - versioned_environment: Environment, - context: str, - variants: list[dict[str, object]], -) -> None: - # Given - update = { - "environment_default": {"variants": variants}, - "segment_overrides": [{"segment_id": segment, "variants": variants}], - }[context] - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - context: update, - }, - format="json", - ) - - # Then - assert response.status_code == 400 - assert "variants" in str(response.json()) - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert dict( - environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" - ) - ) == {"variant_a": 10, "variant_b": 20} - - -def test_update_flag__unknown_feature__responds_400( - admin_client: APIClient, - environment_api_key: str, - versioned_environment: Environment, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"name": "unknown_feature"}, - "environment_default": {"enabled": True}, - }, - format="json", - ) - - # Then - assert response.status_code == 400 - assert "feature" in str(response.json()).lower() - - -def test_update_flag__unknown_segment__responds_400( - admin_client: APIClient, - environment_api_key: str, - feature: int, -) -> None: - # Given / When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "segment_overrides": [ - {"segment_id": 999999, "enabled": True}, - ], - }, - format="json", - ) - - # Then - assert response.status_code == 400 - assert "segment" in str(response.json()).lower() - - -def test_update_flag__change_requests_enabled__responds_400( - admin_client: APIClient, - environment_api_key: str, - feature: int, - versioned_environment: Environment, -) -> None: - # Given - versioned_environment.minimum_change_request_approvals = 0 - versioned_environment.save() - - # When - response = admin_client.post( - f"/api/experiments/environments/{environment_api_key}/update-flag/", - { - "feature": {"id": feature}, - "environment_default": {"enabled": True}, - }, - format="json", - ) - - # Then - assert response.status_code == 400 - environment_default = FeatureState.objects.get_live_feature_states( - environment=versioned_environment, - feature_id=feature, - feature_segment=None, - ).get() - assert environment_default.enabled is False diff --git a/api/tests/integration/helpers.py b/api/tests/integration/helpers.py index 0145d252c513..cd98edd267bc 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: int, + feature_id: str, default_percentage_allocation: float, value: str, key: str | None = None, From 22789005f0745e9cc679b2c976a743576cd9933a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 13 Aug 2026 21:48:28 -0300 Subject: [PATCH 12/39] ah the happy path --- api/features/future/__init__.py | 0 api/features/future/types.py | 58 ++ api/tests/integration/conftest.py | 33 +- .../identities/test_integration_identities.py | 18 +- .../integration/features/future/__init__.py | 0 .../future/test_update_flag_endpoint.py | 757 ++++++++++++++++++ api/tests/integration/helpers.py | 2 +- 7 files changed, 844 insertions(+), 24 deletions(-) create mode 100644 api/features/future/__init__.py create mode 100644 api/features/future/types.py create mode 100644 api/tests/integration/features/future/__init__.py create mode 100644 api/tests/integration/features/future/test_update_flag_endpoint.py diff --git a/api/features/future/__init__.py b/api/features/future/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/future/types.py b/api/features/future/types.py new file mode 100644 index 000000000000..9a7b5ade11a8 --- /dev/null +++ b/api/features/future/types.py @@ -0,0 +1,58 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from collections.abc import Sequence +from typing import NotRequired, TypedDict + +from features.feature_states.models import FeatureValueType + + +class FlagValue(TypedDict): + type: FeatureValueType + value: str + + +class Variant(TypedDict): + key: str + weight: float # Percentage between 0 and 100 + + +class SegmentReference(TypedDict): + id: int + + +class EnvironmentDefaultRequest(TypedDict, total=False): + enabled: bool + value: FlagValue + variants: Sequence[Variant] + + +class SegmentOverrideRequest(TypedDict): + segment: SegmentReference + enabled: NotRequired[bool] + priority: NotRequired[int] + value: NotRequired[FlagValue] + variants: NotRequired[Sequence[Variant]] + + +class UpdateFlagRequest(TypedDict, total=False): + environment_default: EnvironmentDefaultRequest + segment_overrides: Sequence[SegmentOverrideRequest] + + +class EnvironmentDefaultResponse(TypedDict): + enabled: bool + value: FlagValue | None + variants: list[Variant] + + +class SegmentOverrideResponse(TypedDict): + segment: SegmentReference + priority: int + enabled: bool + value: FlagValue | None + variants: list[Variant] + + +class UpdateFlagResponse(TypedDict): + environment_default: EnvironmentDefaultResponse + segment_overrides: list[SegmentOverrideResponse] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 1adc9aca6f81..0c53d6f9c5bf 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -218,36 +218,41 @@ def server_side_sdk_client( @pytest.fixture() -def default_feature_value(): # type: ignore[no-untyped-def] +def default_feature_value() -> str: return "default_value" @pytest.fixture() -def feature_name(): # type: ignore[no-untyped-def] +def feature_name() -> str: return "feature_1" @pytest.fixture() -def feature_2_name(): # type: ignore[no-untyped-def] +def feature_2_name() -> str: return "feature_2" @pytest.fixture() -def mv_feature_name(): # type: ignore[no-untyped-def] +def mv_feature_name() -> str: return "mv_feature" @pytest.fixture() -def feature(admin_client, project, default_feature_value, feature_name): # type: ignore[no-untyped-def] - data = { - "name": feature_name, - "initial_value": default_feature_value, - "project": project, - } - url = reverse("api-v1:projects:project-features-list", args=[project]) - - response = admin_client.post(url, data=data) - return response.json()["id"] +def feature( + admin_client: APIClient, + project: int, + default_feature_value: str, + feature_name: str, +) -> int: + response = admin_client.post( + f"/api/v1/projects/{project}/features/", + data={ + "name": feature_name, + "initial_value": default_feature_value, + "project": project, + }, + ) + return int(response.json()["id"]) @pytest.fixture() 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/features/future/__init__.py b/api/tests/integration/features/future/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py new file mode 100644 index 000000000000..095e37e240f1 --- /dev/null +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -0,0 +1,757 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +import pytest +from rest_framework.test import APIClient + +from environments.models import Environment +from features.future.types import UpdateFlagRequest +from features.models import FeatureState +from features.versioning.tasks import enable_v2_versioning +from tests.integration.helpers import create_mv_option_with_api + + +@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.fixture() +def segment_2( + admin_client: APIClient, + project: int, +) -> int: + response = admin_client.post( + f"/api/v1/projects/{project}/segments/", + { + "name": "Test Segment 2", + "project": project, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + }, + format="json", + ) + return int(response.json()["id"]) + + +@pytest.fixture() +def mv_feature_variants( + admin_client: APIClient, + project: int, + mv_feature: int, +) -> None: + for key, value, default_percentage_allocation in [ + ("variant_a", "a", 10), + ("variant_b", "b", 20), + ]: + create_mv_option_with_api( + admin_client, + project, + mv_feature, + default_percentage_allocation, + value, + key=key, + ) + + +def test_update_flag__patch_environment_default_enabled__toggles_flag( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [], + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is True + + +def test_update_flag__patch_environment_default_value__updates_value( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"environment_default": {"value": {"type": "integer", "value": "1000"}}} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "integer", "value": "1000"}, + "variants": [], + }, + "segment_overrides": [], + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.get_feature_state_value() == 1000 + + +@pytest.mark.usefixtures("mv_feature_variants") +def test_update_flag__patch_environment_default_variants__reweights_variants( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + mv_feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "environment_default": { + "variants": [ + {"key": "variant_a", "weight": 25}, + {"key": "variant_b", "weight": 25.5}, + ], + }, + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"key": "variant_a", "weight": 25}, + {"key": "variant_b", "weight": 25.5}, + ], + }, + "segment_overrides": [], + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_a": 25, "variant_b": 25.5} + + +def test_update_flag__patch_segment_override_enabled__creates_override( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + ], + } + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ).get(feature_segment__segment_id=segment) + assert override.feature_segment is not None + assert override.feature_segment.priority == 0 + assert override.enabled is True + assert override.get_feature_state_value() == default_feature_value + + +def test_update_flag__patch_segment_override_value__overrides_value( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + { + "segment": {"id": segment}, + "value": {"type": "string", "value": "enterprise"}, + }, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": False, + "value": {"type": "string", "value": "enterprise"}, + "variants": [], + }, + ], + } + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert ( + live_feature_states.get( + feature_segment__segment_id=segment + ).get_feature_state_value() + == "enterprise" + ) + assert ( + live_feature_states.get(feature_segment=None).get_feature_state_value() + == default_feature_value + ) + + +def test_update_flag__patch_segment_override_without_value__inherits_environment_default_value( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"environment_default": {"value": {"type": "string", "value": "control"}}} + ), + format="json", + ) + assert setup_response.status_code == 200 + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": "control"}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": True, + "value": {"type": "string", "value": "control"}, + "variants": [], + }, + ], + } + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ).get(feature_segment__segment_id=segment) + assert override.get_feature_state_value() == "control" + + +def test_update_flag__patch_segment_overrides_without_priority__sets_priority_from_position( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + assert [ + (override["segment"]["id"], override["priority"]) + for override in setup_response.json()["segment_overrides"] + ] == [(segment, 0), (segment_2, 1)] # Priority is inferred when creating overrides + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment_2}, "enabled": False}, # No priority + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + { + "segment": {"id": segment_2}, + "priority": 1, # Priority is preserved from previous state + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + ], + } + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .values_list("feature_segment__segment_id", "feature_segment__priority") + ) == {segment: 0, segment_2: 1} + + +def test_update_flag__patch_segment_override_priority__writes_priority_as_given( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "priority": 10, "enabled": True}, + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + assert [ + (override["segment"]["id"], override["priority"]) + for override in setup_response.json()["segment_overrides"] + ] == [(segment_2, 1), (segment, 10)] + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "priority": 1}]} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 1, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + { + "segment": {"id": segment_2}, + "priority": 1, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + ], + } + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .values_list("feature_segment__segment_id", "feature_segment__priority") + ) == {segment: 1, segment_2: 1} + + +@pytest.mark.usefixtures("mv_feature_variants") +def test_update_flag__patch_segment_override_variants__reweights_for_segment_only( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + mv_feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + { + "segment": {"id": segment}, + "variants": [ + {"key": "variant_a", "weight": 25}, + {"key": "variant_b", "weight": 25.5}, + ], + }, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"key": "variant_a", "weight": 10}, + {"key": "variant_b", "weight": 20}, + ], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"key": "variant_a", "weight": 25}, + {"key": "variant_b", "weight": 25.5}, + ], + }, + ], + } + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + ) + assert dict( + live_feature_states.get( + feature_segment__segment_id=segment + ).multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_a": 25, "variant_b": 25.5} + assert dict( + live_feature_states.get( + feature_segment=None + ).multivariate_feature_state_values.values_list( + "multivariate_feature_option__key", "percentage_allocation" + ) + ) == {"variant_a": 10, "variant_b": 20} + + +def test_update_flag__put_environment_default__replaces_environment_default( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + }, + } + ), + format="json", + ) + assert setup_response.status_code == 200 + + # When + response = admin_client.put( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": True, + "value": None, + "variants": [], + }, + "segment_overrides": [], + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is True + assert environment_default.get_feature_state_value() is None + + +def test_update_flag__put_segment_overrides__replaces_segment_overrides( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + }, + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + + # When + response = admin_client.put( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 10, + "value": {"type": "string", "value": "enterprise"}, + }, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 10, + "enabled": False, + "value": {"type": "string", "value": "enterprise"}, + "variants": [], + }, + ], + } + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert dict( + live_feature_states.exclude(feature_segment=None).values_list( + "feature_segment__segment_id", "feature_segment__priority" + ) + ) == {segment: 10} + assert ( + live_feature_states.get(feature_segment=None).get_feature_state_value() + == "control" + ) + + +def test_update_flag__put_environment_default_and_segment_overrides__replaces_both( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + }, + "segment_overrides": [ + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + + # When + response = admin_client.put( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "environment_default": { + "enabled": False, + "value": {"type": "integer", "value": "42"}, + }, + "segment_overrides": [ + {"segment": {"id": segment}, "priority": 5, "enabled": True}, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "integer", "value": "42"}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 5, + "enabled": True, + # Inherited from the environment default written in the same request + "value": {"type": "integer", "value": "42"}, + "variants": [], + }, + ], + } + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert live_feature_states.get(feature_segment=None).get_feature_state_value() == 42 + assert dict( + live_feature_states.exclude(feature_segment=None).values_list( + "feature_segment__segment_id", "feature_segment__priority" + ) + ) == {segment: 5} + assert ( + live_feature_states.get( + feature_segment__segment_id=segment + ).get_feature_state_value() + == 42 + ) 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, From 3209f050c3a6d3b7f8b28adc7715817cad667b86 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 13 Aug 2026 21:58:55 -0300 Subject: [PATCH 13/39] be conservative --- api/features/future/types.py | 2 +- .../future/test_update_flag_endpoint.py | 56 ++++++++++--------- docs/docs/managing-flags/updating-flags.md | 8 +-- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/api/features/future/types.py b/api/features/future/types.py index 9a7b5ade11a8..6374963b3f67 100644 --- a/api/features/future/types.py +++ b/api/features/future/types.py @@ -12,7 +12,7 @@ class FlagValue(TypedDict): class Variant(TypedDict): - key: str + id: int weight: float # Percentage between 0 and 100 diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 095e37e240f1..620b829452aa 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -42,19 +42,17 @@ def mv_feature_variants( admin_client: APIClient, project: int, mv_feature: int, -) -> None: - for key, value, default_percentage_allocation in [ - ("variant_a", "a", 10), - ("variant_b", "b", 20), - ]: +) -> list[int]: + return [ create_mv_option_with_api( admin_client, project, mv_feature, default_percentage_allocation, value, - key=key, ) + for value, default_percentage_allocation in [("a", 10), ("b", 20)] + ] def test_update_flag__patch_environment_default_enabled__toggles_flag( @@ -130,23 +128,26 @@ def test_update_flag__patch_environment_default_value__updates_value( assert environment_default.get_feature_state_value() == 1000 -@pytest.mark.usefixtures("mv_feature_variants") def test_update_flag__patch_environment_default_variants__reweights_variants( admin_client: APIClient, default_feature_value: str, environment_api_key: str, mv_feature: int, + mv_feature_variants: list[int], versioned_environment: Environment, ) -> None: - # Given / When + # Given + variant_a, variant_b = mv_feature_variants + + # When response = admin_client.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { "environment_default": { "variants": [ - {"key": "variant_a", "weight": 25}, - {"key": "variant_b", "weight": 25.5}, + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, ], }, } @@ -161,8 +162,8 @@ def test_update_flag__patch_environment_default_variants__reweights_variants( "enabled": False, "value": {"type": "string", "value": default_feature_value}, "variants": [ - {"key": "variant_a", "weight": 25}, - {"key": "variant_b", "weight": 25.5}, + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, ], }, "segment_overrides": [], @@ -174,9 +175,9 @@ def test_update_flag__patch_environment_default_variants__reweights_variants( ).get() assert dict( environment_default.multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" + "multivariate_feature_option_id", "percentage_allocation" ) - ) == {"variant_a": 25, "variant_b": 25.5} + ) == {variant_a: 25, variant_b: 25.5} def test_update_flag__patch_segment_override_enabled__creates_override( @@ -481,16 +482,19 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( ) == {segment: 1, segment_2: 1} -@pytest.mark.usefixtures("mv_feature_variants") def test_update_flag__patch_segment_override_variants__reweights_for_segment_only( admin_client: APIClient, default_feature_value: str, environment_api_key: str, mv_feature: int, + mv_feature_variants: list[int], segment: int, versioned_environment: Environment, ) -> None: - # Given / When + # Given + variant_a, variant_b = mv_feature_variants + + # When response = admin_client.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( @@ -499,8 +503,8 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl { "segment": {"id": segment}, "variants": [ - {"key": "variant_a", "weight": 25}, - {"key": "variant_b", "weight": 25.5}, + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, ], }, ], @@ -516,8 +520,8 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl "enabled": False, "value": {"type": "string", "value": default_feature_value}, "variants": [ - {"key": "variant_a", "weight": 10}, - {"key": "variant_b", "weight": 20}, + {"id": variant_a, "weight": 10}, + {"id": variant_b, "weight": 20}, ], }, "segment_overrides": [ @@ -527,8 +531,8 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl "enabled": False, "value": {"type": "string", "value": default_feature_value}, "variants": [ - {"key": "variant_a", "weight": 25}, - {"key": "variant_b", "weight": 25.5}, + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, ], }, ], @@ -541,16 +545,16 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl live_feature_states.get( feature_segment__segment_id=segment ).multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" + "multivariate_feature_option_id", "percentage_allocation" ) - ) == {"variant_a": 25, "variant_b": 25.5} + ) == {variant_a: 25, variant_b: 25.5} assert dict( live_feature_states.get( feature_segment=None ).multivariate_feature_state_values.values_list( - "multivariate_feature_option__key", "percentage_allocation" + "multivariate_feature_option_id", "percentage_allocation" ) - ) == {"variant_a": 10, "variant_b": 20} + ) == {variant_a: 10, variant_b: 20} def test_update_flag__put_environment_default__replaces_environment_default( diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index bb83edd27e4e..f915bb41839c 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -147,8 +147,8 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen -d '{ "environment_default": { "variants": [ - {"key": "variant_a", "weight": 10}, - {"key": "variant_b", "weight": 10.5} + {"id": 33, "weight": 10}, + {"id": 34, "weight": 10.5} ] } }' @@ -165,8 +165,8 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen { "segment": {"id": 101}, "variants": [ - {"key": "variant_a", "weight": 25}, - {"key": "variant_b", "weight": 25} + {"id": 33, "weight": 25}, + {"id": 34, "weight": 25} ] } ] From 9fbed002e475f5c10a92efcd55d5078fc04911b2 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 13 Aug 2026 22:08:48 -0300 Subject: [PATCH 14/39] responses! --- docs/docs/managing-flags/updating-flags.md | 135 +++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index f915bb41839c..5ef6e73ad748 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -36,6 +36,8 @@ Values are passed as a `value` object with a `type` and a `value` string: | `integer` | `{"type": "integer", "value": "42"}` | | `boolean` | `{"type": "boolean", "value": "true"}` | +Both methods respond with the flag's complete state in the environment, whichever properties were sent. + ### Toggle a flag on or off The simplest case — enable a feature flag in an environment: @@ -49,6 +51,16 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": true, "value": {"type": "string", "value": "hello"}, "variants": []}, + "segment_overrides": [] +} +``` + ### Update a feature value Change a feature's default value in an environment: @@ -64,6 +76,16 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": true, "value": {"type": "integer", "value": "1000"}, "variants": []}, + "segment_overrides": [] +} +``` + ### Roll out a feature to a segment Enable a flag for one or more segments, while keeping it off for everyone else: @@ -91,6 +113,31 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": false, "value": {"type": "string", "value": "standard"}, "variants": []}, + "segment_overrides": [ + { + "segment": {"id": 101}, + "priority": 10, + "enabled": true, + "value": {"type": "string", "value": "standard"}, + "variants": [] + }, + { + "segment": {"id": 202}, + "priority": 20, + "enabled": true, + "value": {"type": "string", "value": "standard"}, + "variants": [] + } + ] +} +``` + Segments can also override the feature's value for the environment: ```bash @@ -107,6 +154,31 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": false, "value": {"type": "string", "value": "standard"}, "variants": []}, + "segment_overrides": [ + { + "segment": {"id": 101}, + "priority": 10, + "enabled": true, + "value": {"type": "string", "value": "enterprise"}, + "variants": [] + }, + { + "segment": {"id": 202}, + "priority": 20, + "enabled": true, + "value": {"type": "string", "value": "standard"}, + "variants": [] + } + ] +} +``` + Overrides listed in a `PATCH` payload are added or updated by segment; overrides not listed are left unchanged. When adding a new segment override, if `priority` is omitted, it defaults to the override's position in the `segment_overrides` list. The lowest number has the highest priority. @@ -131,6 +203,24 @@ curl -X PUT 'https://api.flagsmith.com/api/__future__/environments/{environment_ }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": false, "value": {"type": "string", "value": "standard"}, "variants": []}, + "segment_overrides": [ + { + "segment": {"id": 101}, + "priority": 10, + "enabled": false, + "value": {"type": "string", "value": "enterprise"}, + "variants": [] + } + ] +} +``` + ### Re-weight variants (A/B/n) On previously configured multivariate features (e.g. experiments), the weight of each variant can be adjusted in the @@ -154,6 +244,23 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": { + "enabled": true, + "value": {"type": "string", "value": "control"}, + "variants": [ + {"id": 33, "weight": 10}, + {"id": 34, "weight": 10.5} + ] + }, + "segment_overrides": [] +} +``` + Within the same request as above, or separately, you can also set different weights for a segment: ```bash @@ -173,5 +280,33 @@ curl -X PATCH 'https://api.flagsmith.com/api/__future__/environments/{environmen }' ``` +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": { + "enabled": true, + "value": {"type": "string", "value": "control"}, + "variants": [ + {"id": 33, "weight": 10}, + {"id": 34, "weight": 10.5} + ] + }, + "segment_overrides": [ + { + "segment": {"id": 101}, + "priority": 0, + "enabled": true, + "value": {"type": "string", "value": "control"}, + "variants": [ + {"id": 33, "weight": 25}, + {"id": 34, "weight": 25} + ] + } + ] +} +``` + In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the feature, even if their weight is zero, and regardless of the HTTP verb used (`PUT` or `PATCH`). From 03992379aa701d30e815439d364168aa0175449a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 09:40:52 -0300 Subject: [PATCH 15/39] deny --- .../future/test_update_flag_endpoint.py | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 620b829452aa..6d199a8bada5 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -1,13 +1,20 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" import pytest +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, +) from rest_framework.test import APIClient from environments.models import Environment from features.future.types import UpdateFlagRequest from features.models import FeatureState from features.versioning.tasks import enable_v2_versioning +from organisations.models import Organisation from tests.integration.helpers import create_mv_option_with_api +from tests.types import WithEnvironmentPermissionsCallable +from users.models import FFAdminUser @pytest.fixture(params=["feature_versioning_v1", "feature_versioning_v2"], autouse=True) @@ -37,6 +44,28 @@ def segment_2( return int(response.json()["id"]) +@pytest.fixture() +def segment_in_other_project( + admin_client: APIClient, + organisation: int, +) -> int: + other_project = admin_client.post( + "/api/v1/projects/", + {"name": "Other Project", "organisation": organisation}, + format="json", + ).json()["id"] + response = admin_client.post( + f"/api/v1/projects/{other_project}/segments/", + { + "name": "Other Segment", + "project": other_project, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + }, + format="json", + ) + return int(response.json()["id"]) + + @pytest.fixture() def mv_feature_variants( admin_client: APIClient, @@ -759,3 +788,320 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo ).get_feature_state_value() == 42 ) + + +def test_update_flag__change_requests_enabled__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given + versioned_environment.minimum_change_request_approvals = 2 + versioned_environment.save() + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "detail": "Cannot update flags in an environment with change requests enabled.", + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False + + +def test_update_flag__value_not_matching_type__responds_400( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"environment_default": {"value": {"type": "integer", "value": "abc"}}} + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": {"value": ["'abc' is not a valid integer"]}, + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.get_feature_state_value() == default_feature_value + + +def test_update_flag__unknown_feature__responds_404( + admin_client: APIClient, + environment_api_key: str, + feature: int, +) -> None: + # Given + unknown_feature = feature + 1 + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{unknown_feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_update_flag__unknown_environment__responds_404( + admin_client: APIClient, + feature: int, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/unknown-api-key/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_update_flag__user_without_environment_permissions__responds_404( + non_admin_client: APIClient, + environment_api_key: str, + feature: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = non_admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False + + +def test_update_flag__unknown_segment__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + unknown_segment = segment + 1 + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": unknown_segment}, "enabled": True}, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "segment_overrides": [{"segment": {"id": ["Segment not found."]}}], + } + assert ( + not FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .exists() + ) + + +def test_update_flag__duplicate_segment_overrides__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + {"segment": {"id": segment}, "enabled": False}, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "segment_overrides": [f"Duplicate segment: {segment}."], + } + assert ( + not FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .exists() + ) + + +def test_update_flag__segment_from_another_project__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + segment_in_other_project: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment_in_other_project}, "enabled": True}, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "segment_overrides": [{"segment": {"id": ["Segment not found."]}}], + } + assert ( + not FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .exists() + ) + + +def test_update_flag__update_feature_state_permission__gates_environment_default_only( + staff_user: FFAdminUser, + staff_client: APIClient, + environment: int, + environment_api_key: str, + feature: int, + organisation: int, + segment: int, + versioned_environment: Environment, + with_environment_permissions: WithEnvironmentPermissionsCallable, +) -> None: + # Given + staff_user.add_organisation(Organisation.objects.get(id=organisation)) + with_environment_permissions([UPDATE_FEATURE_STATE], environment, False) + + # When + environment_default_response = staff_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + segment_overrides_response = staff_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + ], + } + ), + format="json", + ) + + # Then + assert environment_default_response.status_code == 200 + assert segment_overrides_response.status_code == 403 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + assert live_feature_states.get(feature_segment=None).enabled is True + assert not live_feature_states.exclude(feature_segment=None).exists() + + +def test_update_flag__manage_segment_overrides_permission__gates_segment_overrides_only( + staff_user: FFAdminUser, + staff_client: APIClient, + environment: int, + environment_api_key: str, + feature: int, + organisation: int, + segment: int, + versioned_environment: Environment, + with_environment_permissions: WithEnvironmentPermissionsCallable, +) -> None: + # Given + staff_user.add_organisation(Organisation.objects.get(id=organisation)) + with_environment_permissions([MANAGE_SEGMENT_OVERRIDES], environment, False) + + # When + segment_overrides_response = staff_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + ], + } + ), + format="json", + ) + environment_default_response = staff_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert segment_overrides_response.status_code == 200 + assert environment_default_response.status_code == 403 + live_feature_states = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + override = live_feature_states.get(feature_segment__segment_id=segment) + assert override.enabled is True + assert live_feature_states.get(feature_segment=None).enabled is False From 2d87c6f886263e64174e9658deaf21145df1249a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 09:57:14 -0300 Subject: [PATCH 16/39] it may vary --- .../future/test_update_flag_endpoint.py | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 6d199a8bada5..36afa6a0d91b 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -1105,3 +1105,165 @@ def test_update_flag__manage_segment_overrides_permission__gates_segment_overrid override = live_feature_states.get(feature_segment__segment_id=segment) assert override.enabled is True assert live_feature_states.get(feature_segment=None).enabled is False + + +def test_update_flag__unknown_variant__responds_400( + admin_client: APIClient, + environment_api_key: str, + mv_feature: int, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + unknown_variant = variant_b + 1 + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "environment_default": { + "variants": [ + {"id": variant_a, "weight": 10}, + {"id": unknown_variant, "weight": 20}, + ], + }, + } + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": { + "variants": ["Variant not found."], + }, + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 10, variant_b: 20} + + +def test_update_flag__variant_omitted__responds_400( + admin_client: APIClient, + environment_api_key: str, + mv_feature: int, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + {"environment_default": {"variants": [{"id": variant_a, "weight": 30}]}} + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": { + "variants": ["Must include all feature's variants."], + }, + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 10, variant_b: 20} + + +def test_update_flag__variant_weights_over_100__responds_400( + admin_client: APIClient, + environment_api_key: str, + mv_feature: int, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "environment_default": { + "variants": [ + {"id": variant_a, "weight": 60}, + {"id": variant_b, "weight": 50}, + ], + }, + } + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": {"variants": ["Total weight must not exceed 100."]}, + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + feature_segment=None, + ).get() + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 10, variant_b: 20} + + +def test_update_flag__variants_on_standard_feature__responds_400( + admin_client: APIClient, + environment_api_key: str, + feature: int, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, _ = mv_feature_variants + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"environment_default": {"variants": [{"id": variant_a, "weight": 10}]}} + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": {"variants": ["Feature is not multivariate."]}, + } + assert ( + not FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + feature_segment=None, + ) + .get() + .multivariate_feature_state_values.exists() + ) From 96b4b7d04ca449e5c478bb10fbff5492122d4e7f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 10:56:52 -0300 Subject: [PATCH 17/39] expect telemetry --- .../future/test_update_flag_endpoint.py | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 36afa6a0d91b..4e031c9b9285 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -5,6 +5,7 @@ MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE, ) +from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient from environments.models import Environment @@ -89,6 +90,7 @@ def test_update_flag__patch_environment_default_enabled__toggles_flag( default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given @@ -122,12 +124,26 @@ def test_update_flag__patch_environment_default_enabled__toggles_flag( feature_segment=None, ).get() assert environment_default.enabled is True + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_environment_default_value__updates_value( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given / When @@ -155,12 +171,26 @@ def test_update_flag__patch_environment_default_value__updates_value( feature_segment=None, ).get() assert environment_default.get_feature_state_value() == 1000 + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_environment_default_variants__reweights_variants( admin_client: APIClient, default_feature_value: str, environment_api_key: str, + log: StructuredLogCapture, mv_feature: int, mv_feature_variants: list[int], versioned_environment: Environment, @@ -207,6 +237,19 @@ def test_update_flag__patch_environment_default_variants__reweights_variants( "multivariate_feature_option_id", "percentage_allocation" ) ) == {variant_a: 25, variant_b: 25.5} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": mv_feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_override_enabled__creates_override( @@ -214,6 +257,7 @@ def test_update_flag__patch_segment_override_enabled__creates_override( default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, versioned_environment: Environment, ) -> None: @@ -252,6 +296,19 @@ def test_update_flag__patch_segment_override_enabled__creates_override( assert override.feature_segment.priority == 0 assert override.enabled is True assert override.get_feature_state_value() == default_feature_value + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_override_value__overrides_value( @@ -259,6 +316,7 @@ def test_update_flag__patch_segment_override_value__overrides_value( default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, versioned_environment: Environment, ) -> None: @@ -310,12 +368,26 @@ def test_update_flag__patch_segment_override_value__overrides_value( live_feature_states.get(feature_segment=None).get_feature_state_value() == default_feature_value ) + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_override_without_value__inherits_environment_default_value( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, versioned_environment: Environment, ) -> None: @@ -328,6 +400,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment format="json", ) assert setup_response.status_code == 200 + log.events.clear() # When response = admin_client.patch( @@ -361,6 +434,19 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment feature_id=feature, ).get(feature_segment__segment_id=segment) assert override.get_feature_state_value() == "control" + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_overrides_without_priority__sets_priority_from_position( @@ -368,6 +454,7 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, segment_2: int, versioned_environment: Environment, @@ -390,6 +477,7 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr (override["segment"]["id"], override["priority"]) for override in setup_response.json()["segment_overrides"] ] == [(segment, 0), (segment_2, 1)] # Priority is inferred when creating overrides + log.events.clear() # When response = admin_client.patch( @@ -437,6 +525,19 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr .exclude(feature_segment=None) .values_list("feature_segment__segment_id", "feature_segment__priority") ) == {segment: 0, segment_2: 1} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [segment_2], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_override_priority__writes_priority_as_given( @@ -444,6 +545,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, segment_2: int, versioned_environment: Environment, @@ -466,6 +568,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( (override["segment"]["id"], override["priority"]) for override in setup_response.json()["segment_overrides"] ] == [(segment_2, 1), (segment, 10)] + log.events.clear() # When response = admin_client.patch( @@ -509,12 +612,26 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( .exclude(feature_segment=None) .values_list("feature_segment__segment_id", "feature_segment__priority") ) == {segment: 1, segment_2: 1} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [segment], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__patch_segment_override_variants__reweights_for_segment_only( admin_client: APIClient, default_feature_value: str, environment_api_key: str, + log: StructuredLogCapture, mv_feature: int, mv_feature_variants: list[int], segment: int, @@ -584,12 +701,26 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl "multivariate_feature_option_id", "percentage_allocation" ) ) == {variant_a: 10, variant_b: 20} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": mv_feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__put_environment_default__replaces_environment_default( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given @@ -606,6 +737,7 @@ def test_update_flag__put_environment_default__replaces_environment_default( format="json", ) assert setup_response.status_code == 200 + log.events.clear() # When response = admin_client.put( @@ -631,12 +763,26 @@ def test_update_flag__put_environment_default__replaces_environment_default( ).get() assert environment_default.enabled is True assert environment_default.get_feature_state_value() is None + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__put_segment_overrides__replaces_segment_overrides( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, segment_2: int, versioned_environment: Environment, @@ -659,6 +805,7 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( format="json", ) assert setup_response.status_code == 200 + log.events.clear() # When response = admin_client.put( @@ -708,12 +855,26 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( live_feature_states.get(feature_segment=None).get_feature_state_value() == "control" ) + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [segment], + "segment_overrides__deleted__segment__ids": [segment_2], + }, + ] def test_update_flag__put_environment_default_and_segment_overrides__replaces_both( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, segment_2: int, versioned_environment: Environment, @@ -735,6 +896,7 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo format="json", ) assert setup_response.status_code == 200 + log.events.clear() # When response = admin_client.put( @@ -788,12 +950,26 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo ).get_feature_state_value() == 42 ) + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [segment_2], + }, + ] def test_update_flag__change_requests_enabled__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given @@ -818,6 +994,17 @@ def test_update_flag__change_requests_enabled__responds_400( feature_segment=None, ).get() assert environment_default.enabled is False + assert log.events == [ + { + "level": "warning", + "event": "flag.update_rejected", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "reason": "change_requests_enabled", + }, + ] def test_update_flag__value_not_matching_type__responds_400( @@ -825,6 +1012,7 @@ def test_update_flag__value_not_matching_type__responds_400( default_feature_value: str, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given / When @@ -847,12 +1035,14 @@ def test_update_flag__value_not_matching_type__responds_400( feature_segment=None, ).get() assert environment_default.get_feature_state_value() == default_feature_value + assert log.events == [] def test_update_flag__unknown_feature__responds_404( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, ) -> None: # Given unknown_feature = feature + 1 @@ -867,11 +1057,13 @@ def test_update_flag__unknown_feature__responds_404( # Then assert response.status_code == 404 assert response.json() == {"detail": "Not found."} + assert log.events == [] def test_update_flag__unknown_environment__responds_404( admin_client: APIClient, feature: int, + log: StructuredLogCapture, ) -> None: # Given / When response = admin_client.patch( @@ -883,12 +1075,14 @@ def test_update_flag__unknown_environment__responds_404( # Then assert response.status_code == 404 assert response.json() == {"detail": "Not found."} + assert log.events == [] def test_update_flag__user_without_environment_permissions__responds_404( non_admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given / When @@ -907,12 +1101,14 @@ def test_update_flag__user_without_environment_permissions__responds_404( feature_segment=None, ).get() assert environment_default.enabled is False + assert log.events == [] def test_update_flag__unknown_segment__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, versioned_environment: Environment, ) -> None: @@ -945,12 +1141,14 @@ def test_update_flag__unknown_segment__responds_400( .exclude(feature_segment=None) .exists() ) + assert log.events == [] def test_update_flag__duplicate_segment_overrides__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment: int, versioned_environment: Environment, ) -> None: @@ -981,12 +1179,14 @@ def test_update_flag__duplicate_segment_overrides__responds_400( .exclude(feature_segment=None) .exists() ) + assert log.events == [] def test_update_flag__segment_from_another_project__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, segment_in_other_project: int, versioned_environment: Environment, ) -> None: @@ -1016,6 +1216,7 @@ def test_update_flag__segment_from_another_project__responds_400( .exclude(feature_segment=None) .exists() ) + assert log.events == [] def test_update_flag__update_feature_state_permission__gates_environment_default_only( @@ -1024,6 +1225,7 @@ def test_update_flag__update_feature_state_permission__gates_environment_default environment: int, environment_api_key: str, feature: int, + log: StructuredLogCapture, organisation: int, segment: int, versioned_environment: Environment, @@ -1060,6 +1262,19 @@ def test_update_flag__update_feature_state_permission__gates_environment_default ) assert live_feature_states.get(feature_segment=None).enabled is True assert not live_feature_states.exclude(feature_segment=None).exists() + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__manage_segment_overrides_permission__gates_segment_overrides_only( @@ -1068,6 +1283,7 @@ def test_update_flag__manage_segment_overrides_permission__gates_segment_overrid environment: int, environment_api_key: str, feature: int, + log: StructuredLogCapture, organisation: int, segment: int, versioned_environment: Environment, @@ -1105,11 +1321,25 @@ def test_update_flag__manage_segment_overrides_permission__gates_segment_overrid override = live_feature_states.get(feature_segment__segment_id=segment) assert override.enabled is True assert live_feature_states.get(feature_segment=None).enabled is False + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] def test_update_flag__unknown_variant__responds_400( admin_client: APIClient, environment_api_key: str, + log: StructuredLogCapture, mv_feature: int, mv_feature_variants: list[int], versioned_environment: Environment, @@ -1151,11 +1381,13 @@ def test_update_flag__unknown_variant__responds_400( "multivariate_feature_option_id", "percentage_allocation" ) ) == {variant_a: 10, variant_b: 20} + assert log.events == [] def test_update_flag__variant_omitted__responds_400( admin_client: APIClient, environment_api_key: str, + log: StructuredLogCapture, mv_feature: int, mv_feature_variants: list[int], versioned_environment: Environment, @@ -1189,11 +1421,13 @@ def test_update_flag__variant_omitted__responds_400( "multivariate_feature_option_id", "percentage_allocation" ) ) == {variant_a: 10, variant_b: 20} + assert log.events == [] def test_update_flag__variant_weights_over_100__responds_400( admin_client: APIClient, environment_api_key: str, + log: StructuredLogCapture, mv_feature: int, mv_feature_variants: list[int], versioned_environment: Environment, @@ -1232,12 +1466,14 @@ def test_update_flag__variant_weights_over_100__responds_400( "multivariate_feature_option_id", "percentage_allocation" ) ) == {variant_a: 10, variant_b: 20} + assert log.events == [] def test_update_flag__variants_on_standard_feature__responds_400( admin_client: APIClient, environment_api_key: str, feature: int, + log: StructuredLogCapture, mv_feature_variants: list[int], versioned_environment: Environment, ) -> None: @@ -1267,3 +1503,4 @@ def test_update_flag__variants_on_standard_feature__responds_400( .get() .multivariate_feature_state_values.exists() ) + assert log.events == [] From f4bddecfbcc8ae26df354eccf720d273ae50ed7d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 14:09:38 -0300 Subject: [PATCH 18/39] IT'S GREEN --- api/api/urls/future.py | 20 ++ api/app/urls.py | 3 + api/experimentation/services.py | 11 +- api/features/feature_states/models.py | 6 + api/features/future/exceptions.py | 13 + api/features/future/mappers.py | 64 ++++ api/features/future/permissions.py | 35 ++ api/features/future/serializers.py | 113 +++++++ api/features/future/services.py | 315 ++++++++++++++++++ api/features/future/views.py | 98 ++++++ api/tests/integration/conftest.py | 11 +- .../future/test_update_flag_endpoint.py | 221 ++++++++++++ .../observability/_events-catalogue.md | 54 ++- 13 files changed, 939 insertions(+), 25 deletions(-) create mode 100644 api/api/urls/future.py create mode 100644 api/features/future/exceptions.py create mode 100644 api/features/future/mappers.py create mode 100644 api/features/future/permissions.py create mode 100644 api/features/future/serializers.py create mode 100644 api/features/future/services.py create mode 100644 api/features/future/views.py diff --git a/api/api/urls/future.py b/api/api/urls/future.py new file mode 100644 index 000000000000..0a1d14b4e5bd --- /dev/null +++ b/api/api/urls/future.py @@ -0,0 +1,20 @@ +""" +Experimental API endpoints intended to become canonical. + +These endpoints are subject to change and should not be considered stable. +Use at your own risk - breaking changes may occur without prior notice. +""" + +from django.urls import path + +from features.future.views import UpdateFlagAPIView + +app_name = "future" + +urlpatterns = [ + path( + "environments//features//", + UpdateFlagAPIView.as_view(), + name="update-flag", + ), +] diff --git a/api/app/urls.py b/api/app/urls.py index 670aa258aab8..1b45a36d8732 100644 --- a/api/app/urls.py +++ b/api/app/urls.py @@ -37,6 +37,9 @@ r"^api/experiments/", include("api.urls.experiments", namespace="api-experiments"), ), + re_path( + r"^api/__future__/", include("api.urls.future", namespace="api-future") + ), re_path(r"^admin/", admin.site.urls), re_path( r"^sales-dashboard/", diff --git a/api/experimentation/services.py b/api/experimentation/services.py index f9a3d3cab269..514246996a84 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -70,8 +70,9 @@ compare_to_control, srm_p_value, ) +from features.feature_states.models import API_VALUE_TYPES from features.models import FeatureState -from features.value_types import BOOLEAN, INTEGER, STRING +from features.value_types import BOOLEAN, STRING from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet from features.versioning.versioning_service import ( get_environment_flags_list, @@ -84,12 +85,6 @@ # TODO: Delete alias as per https://github.com/Flagsmith/flagsmith/issues/7818 from segments.types import SegmentRule as SegmentRuleType -_ROLLOUT_VALUE_TYPE: dict[str, "FeatureValueType"] = { - INTEGER: "integer", - STRING: "string", - BOOLEAN: "boolean", -} - if typing.TYPE_CHECKING: from collections.abc import Sequence from datetime import datetime @@ -858,7 +853,7 @@ def _serialize_feature_state_value( return "", "string" return ( str(value.value).lower() if value.type == BOOLEAN else str(value.value), - _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"), + API_VALUE_TYPES.get(value.type or STRING, "string"), ) diff --git a/api/features/feature_states/models.py b/api/features/feature_states/models.py index 05486661e97e..ab69177bf144 100644 --- a/api/features/feature_states/models.py +++ b/api/features/feature_states/models.py @@ -14,6 +14,12 @@ # TODO: use Pydantic TypeAdapter to map serializer data to DTOs FeatureValueType = Literal["string", "integer", "boolean"] +API_VALUE_TYPES: dict[str, FeatureValueType] = { + INTEGER: "integer", + STRING: "string", + BOOLEAN: "boolean", +} + class AbstractBaseFeatureValueModel(models.Model): class Meta: diff --git a/api/features/future/exceptions.py b/api/features/future/exceptions.py new file mode 100644 index 000000000000..e86dd59b73f6 --- /dev/null +++ b/api/features/future/exceptions.py @@ -0,0 +1,13 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from rest_framework import status +from rest_framework.exceptions import APIException + + +class ChangeRequestsEnabledError(APIException): + """Raised where a flag can only be changed by going through a change request.""" + + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "Cannot update flags in an environment with change requests enabled." + ) diff --git a/api/features/future/mappers.py b/api/features/future/mappers.py new file mode 100644 index 000000000000..b652415d6997 --- /dev/null +++ b/api/features/future/mappers.py @@ -0,0 +1,64 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from features.feature_states.models import API_VALUE_TYPES +from features.models import FeatureState, FeatureStateValue + +from .types import ( + EnvironmentDefaultResponse, + FlagValue, + SegmentOverrideResponse, + SegmentReference, + Variant, +) + + +def map_flag_value(feature_state_value: FeatureStateValue) -> FlagValue | None: + """Render a stored value as a typed value object, always with a string value.""" + value = feature_state_value.value + if value is None: + return None + return FlagValue( + type=API_VALUE_TYPES.get(feature_state_value.type, "string"), # type: ignore[arg-type] + value=("true" if value else "false") if isinstance(value, bool) else str(value), + ) + + +def map_variants(feature_state: FeatureState) -> list[Variant]: + """List a feature state's variant weights, ordered by variant.""" + return [ + Variant( + id=multivariate_value.multivariate_feature_option_id, + weight=multivariate_value.percentage_allocation, + ) + for multivariate_value in sorted( + feature_state.multivariate_feature_state_values.all(), + key=lambda multivariate_value: ( + multivariate_value.multivariate_feature_option_id + ), + ) + ] + + +def map_environment_default(feature_state: FeatureState) -> EnvironmentDefaultResponse: + """Render a feature state as the flag's default for its environment.""" + return EnvironmentDefaultResponse( + enabled=feature_state.enabled, + value=map_flag_value(feature_state.feature_state_value), + variants=map_variants(feature_state), + ) + + +def map_segment_override( + feature_state: FeatureState, +) -> SegmentOverrideResponse | None: + """Render a feature state as one of the flag's segment overrides.""" + feature_segment = feature_state.feature_segment + if feature_segment is None: + return None + return SegmentOverrideResponse( + segment=SegmentReference(id=feature_segment.segment_id), + priority=feature_segment.priority, + enabled=feature_state.enabled, + value=map_flag_value(feature_state.feature_state_value), + variants=map_variants(feature_state), + ) diff --git a/api/features/future/permissions.py b/api/features/future/permissions.py new file mode 100644 index 000000000000..4b01dd305188 --- /dev/null +++ b/api/features/future/permissions.py @@ -0,0 +1,35 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from collections.abc import Container + +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, +) +from rest_framework.exceptions import NotFound, PermissionDenied + +from environments.models import Environment +from users.abc import UserABC + +PROPERTY_PERMISSIONS = { + "environment_default": UPDATE_FEATURE_STATE, + "segment_overrides": MANAGE_SEGMENT_OVERRIDES, +} + + +def check_update_permissions( + user: UserABC, environment: Environment, properties: Container[str] +) -> None: + """Authorise a caller to write the flag properties they sent. + + A caller who may write no property at all is not told the environment exists. + """ + denied = { + property_name + for property_name, permission in PROPERTY_PERMISSIONS.items() + if not user.has_environment_permission(permission, environment) + } + if len(denied) == len(PROPERTY_PERMISSIONS): + raise NotFound() + if any(property_name in properties for property_name in denied): + raise PermissionDenied() diff --git a/api/features/future/serializers.py b/api/features/future/serializers.py new file mode 100644 index 000000000000..774f4136daa8 --- /dev/null +++ b/api/features/future/serializers.py @@ -0,0 +1,113 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from rest_framework import serializers + +from features.feature_types import MULTIVARIATE +from features.models import Feature, FeatureStateValue +from segments.models import Segment + +from .types import ( + EnvironmentDefaultRequest, + FlagValue, + SegmentOverrideRequest, + UpdateFlagRequest, + Variant, +) + + +class FlagValueSerializer(serializers.Serializer[FlagValue]): + """A flag value, typed by the caller so it survives the round trip as a string.""" + + type = serializers.ChoiceField(choices=["string", "integer", "boolean"]) + value = serializers.CharField(allow_blank=True) + + +class VariantSerializer(serializers.Serializer[Variant]): + """The share of an environment or segment a multivariate variant is served to.""" + + id = serializers.IntegerField() + weight = serializers.FloatField(min_value=0, max_value=100) + + +class SegmentReferenceSerializer(serializers.Serializer[Segment]): + """A segment of the feature's project, referenced by an override.""" + + id = serializers.IntegerField() + + def validate_id(self, id: int) -> int: + feature: Feature = self.context["feature"] + if not Segment.live_objects.filter( + id=id, project_id=feature.project_id + ).exists(): + raise serializers.ValidationError("Segment not found.") + return id + + +class FlagStateSerializer(serializers.Serializer[dict[str, object]]): + """What a flag serves somewhere in an environment.""" + + enabled = serializers.BooleanField(required=False) + value = FlagValueSerializer(required=False) + variants = VariantSerializer(many=True, required=False) + + def validate_value(self, value: FlagValue) -> FlagValue: + try: + FeatureStateValue().set_value(value["value"], value["type"]) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + return value + + def validate_variants(self, variants: list[Variant]) -> list[Variant]: + feature: Feature = self.context["feature"] + if feature.type != MULTIVARIATE: + raise serializers.ValidationError("Feature is not multivariate.") + known_ids = set(feature.multivariate_options.values_list("id", flat=True)) + given_ids = {variant["id"] for variant in variants} + if given_ids - known_ids: + raise serializers.ValidationError("Variant not found.") + if known_ids - given_ids: + raise serializers.ValidationError("Must include all feature's variants.") + if sum(variant["weight"] for variant in variants) > 100: + raise serializers.ValidationError("Total weight must not exceed 100.") + return variants + + +class EnvironmentDefaultSerializer(FlagStateSerializer): + """What the flag serves to everyone the segment overrides do not match.""" + + def validate(self, attrs: EnvironmentDefaultRequest) -> EnvironmentDefaultRequest: + feature: Feature = self.context["feature"] + if ( + self.context["replace"] + and feature.type == MULTIVARIATE + and "variants" not in attrs + ): + raise serializers.ValidationError( + {"variants": ["Must include all feature's variants."]} + ) + return attrs + + +class SegmentOverrideSerializer(FlagStateSerializer): + """What the flag serves to the identities a segment matches.""" + + segment = SegmentReferenceSerializer() + priority = serializers.IntegerField(min_value=0, required=False) + + +class UpdateFlagSerializer(serializers.Serializer[UpdateFlagRequest]): + """The parts of a flag a caller wants to write in one request.""" + + environment_default = EnvironmentDefaultSerializer(required=False) + segment_overrides = SegmentOverrideSerializer(many=True, required=False) + + def validate_segment_overrides( + self, segment_overrides: list[SegmentOverrideRequest] + ) -> list[SegmentOverrideRequest]: + seen: set[int] = set() + for override in segment_overrides: + segment_id = override["segment"]["id"] + if segment_id in seen: + raise serializers.ValidationError(f"Duplicate segment: {segment_id}.") + seen.add(segment_id) + return segment_overrides diff --git a/api/features/future/services.py b/api/features/future/services.py new file mode 100644 index 000000000000..87e60640ea66 --- /dev/null +++ b/api/features/future/services.py @@ -0,0 +1,315 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from collections.abc import Collection, Sequence +from typing import NamedTuple + +import structlog +from django.db import transaction +from django.db.models import Q + +from api_keys.user import APIKeyUser +from environments.models import Environment +from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue +from features.multivariate.models import MultivariateFeatureStateValue +from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.versioning_service import get_environment_flags_list +from users.models import FFAdminUser + +from .mappers import map_environment_default, map_segment_override, map_variants +from .types import ( + EnvironmentDefaultRequest, + SegmentOverrideRequest, + UpdateFlagRequest, + UpdateFlagResponse, + Variant, +) + +logger = structlog.get_logger("features") + + +def _get_feature_states( + environment: Environment, feature: Feature +) -> list[FeatureState]: + return get_environment_flags_list( + environment, + additional_filters=Q( + feature_id=feature.id, + identity__isnull=True, # Identity overrides are unsupported + ), + additional_prefetch_related_args=["multivariate_feature_state_values"], + ) + + +def _create_draft_version( + environment: Environment, feature: Feature +) -> EnvironmentFeatureVersion | None: + """Create an unpublished version of the flag, cloned from its live state.""" + if not environment.use_v2_feature_versioning: + return None + return EnvironmentFeatureVersion.objects.create( # type: ignore[no-any-return] + environment=environment, feature=feature + ) + + +def _get_feature_states_to_write( + environment: Environment, + feature: Feature, + version: EnvironmentFeatureVersion | None, +) -> list[FeatureState]: + if version is None: + return _get_feature_states(environment, feature) + return list( + version.feature_states.select_related( + "feature_segment", + "feature_state_value", + ).prefetch_related("multivariate_feature_state_values") + ) + + +def _write_variants(feature_state: FeatureState, variants: Sequence[Variant]) -> None: + weighted = { + multivariate_value.multivariate_feature_option_id: multivariate_value + for multivariate_value in feature_state.multivariate_feature_state_values.all() + } + for variant in variants: + if multivariate_value := weighted.get(variant["id"]): + multivariate_value.percentage_allocation = variant["weight"] + multivariate_value.save(update_fields=["percentage_allocation"]) + else: + MultivariateFeatureStateValue.objects.create( + feature_state=feature_state, + multivariate_feature_option_id=variant["id"], + percentage_allocation=variant["weight"], + ) + + +def _clear_value(feature_state_value: FeatureStateValue) -> None: + value_fields = ["string_value", "integer_value", "boolean_value"] + for value_field in value_fields: + setattr(feature_state_value, value_field, None) + feature_state_value.save(update_fields=value_fields) + + +def _write_environment_default( + feature_state: FeatureState, + changes: EnvironmentDefaultRequest, + *, + replace: bool, +) -> None: + if replace or "enabled" in changes: + feature_state.enabled = changes.get("enabled", False) + feature_state.save(update_fields=["enabled"]) + + feature_state_value = feature_state.feature_state_value + if (value := changes.get("value")) is not None: + feature_state_value.set_value(value["value"], value["type"]) + feature_state_value.save() + elif replace: + _clear_value(feature_state_value) + + if (variants := changes.get("variants")) is not None: + _write_variants(feature_state, variants) + + +def _write_segment_override( + feature_state: FeatureState, + changes: SegmentOverrideRequest, + *, + replace: bool, + environment_default: FeatureState, +) -> None: + """Write an override, inheriting from the environment default what it omits.""" + feature_segment = feature_state.feature_segment + if feature_segment is None: # pragma: no cover + raise ValueError("Feature state is not a segment override.") + + if (priority := changes.get("priority")) is not None: + feature_segment.priority = priority + feature_segment.save(update_fields=["priority"]) + + if replace or "enabled" in changes: + feature_state.enabled = changes.get("enabled", False) + feature_state.save(update_fields=["enabled"]) + + feature_state_value = feature_state.feature_state_value + if (value := changes.get("value")) is not None: + feature_state_value.set_value(value["value"], value["type"]) + feature_state_value.save() + elif replace: + feature_state_value.copy_from(environment_default.feature_state_value) + + if (variants := changes.get("variants")) is not None: + _write_variants(feature_state, variants) + elif replace: + _write_variants(feature_state, map_variants(environment_default)) + + +def _create_segment_override( + *, + environment: Environment, + feature: Feature, + version: EnvironmentFeatureVersion | None, + segment_id: int, + priority: int, +) -> FeatureState: + feature_segment = FeatureSegment.objects.create( + feature=feature, + environment=environment, + environment_feature_version=version, + segment_id=segment_id, + priority=priority, + ) + return FeatureState.objects.create( # type: ignore[no-any-return] + feature=feature, + environment=environment, + environment_feature_version=version, + feature_segment=feature_segment, + ) + + +def _delete_segment_overrides( + *, + environment: Environment, + feature: Feature, + version: EnvironmentFeatureVersion | None, + segment_ids: Collection[int], +) -> None: + feature_segments = ( + FeatureSegment.objects.filter(environment=environment, feature=feature) + if version is None + else version.feature_segments.all() + ) + # Deleting a `FeatureSegment` instance decrements every greater priority. + feature_segments.filter(segment_id__in=segment_ids).delete() + + +class WrittenSegmentOverrides(NamedTuple): + created: list[int] + updated: list[int] + deleted: list[int] + + +def _write_segment_overrides( + *, + environment: Environment, + feature: Feature, + version: EnvironmentFeatureVersion | None, + environment_default: FeatureState, + overrides: dict[int, FeatureState], + changes: Sequence[SegmentOverrideRequest], + replace: bool, +) -> WrittenSegmentOverrides: + written = WrittenSegmentOverrides([], [], []) + + if replace: + written.deleted.extend( + sorted(overrides.keys() - {change["segment"]["id"] for change in changes}) + ) + _delete_segment_overrides( + environment=environment, + feature=feature, + version=version, + segment_ids=written.deleted, + ) + + for position, change in enumerate(changes): + segment_id = change["segment"]["id"] + if feature_state := overrides.get(segment_id): + written.updated.append(segment_id) + else: + feature_state = _create_segment_override( + environment=environment, + feature=feature, + version=version, + segment_id=segment_id, + priority=change.get("priority", position), + ) + written.created.append(segment_id) + _write_segment_override( + feature_state, + change, + replace=replace or segment_id in written.created, + environment_default=environment_default, + ) + + return written + + +def update_flag( + *, + environment: Environment, + feature: Feature, + changes: UpdateFlagRequest, + replace: bool, + author: FFAdminUser | APIKeyUser, +) -> UpdateFlagResponse: + """Write the given parts of a flag, whichever versioning the environment uses.""" + written = WrittenSegmentOverrides([], [], []) + + with transaction.atomic(): + version = _create_draft_version(environment, feature) + feature_states = _get_feature_states_to_write(environment, feature, version) + environment_default = next( + feature_state + for feature_state in feature_states + if feature_state.feature_segment_id is None + ) + + if (default_changes := changes.get("environment_default")) is not None: + _write_environment_default( + environment_default, default_changes, replace=replace + ) + + if (override_changes := changes.get("segment_overrides")) is not None: + written = _write_segment_overrides( + environment=environment, + feature=feature, + version=version, + environment_default=environment_default, + overrides={ + feature_segment.segment_id: feature_state + for feature_state in feature_states + if (feature_segment := feature_state.feature_segment) is not None + }, + changes=override_changes, + replace=replace, + ) + + if version is not None: + # `UserABC.__subclasshook__` matches any user against `APIKeyUser` + published_by = author if isinstance(author, FFAdminUser) else None + version.publish( + published_by=published_by, + published_by_api_key=None if published_by else author.key, + ) + + logger.info( + "flag.updated", + organisation__id=environment.project.organisation_id, + project__id=environment.project_id, + environment__id=environment.id, + feature__id=feature.id, + segment_overrides__created__segment__ids=written.created, + segment_overrides__updated__segment__ids=written.updated, + segment_overrides__deleted__segment__ids=written.deleted, + ) + + return get_flag(environment=environment, feature=feature) + + +def get_flag(*, environment: Environment, feature: Feature) -> UpdateFlagResponse: + """Read what a flag serves in an environment.""" + feature_states = _get_feature_states(environment, feature) + return UpdateFlagResponse( + environment_default=map_environment_default( + next( + feature_state + for feature_state in feature_states + if feature_state.feature_segment_id is None + ) + ), + segment_overrides=sorted( + filter(None, map(map_segment_override, feature_states)), + key=lambda override: (override["priority"], override["segment"]["id"]), + ), + ) diff --git a/api/features/future/views.py b/api/features/future/views.py new file mode 100644 index 000000000000..dbdd3c420c97 --- /dev/null +++ b/api/features/future/views.py @@ -0,0 +1,98 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +import structlog +from django.contrib.auth.models import AnonymousUser +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from environments.models import Environment +from features.models import Feature + +from .exceptions import ChangeRequestsEnabledError +from .permissions import check_update_permissions +from .serializers import UpdateFlagSerializer +from .services import update_flag +from .types import UpdateFlagRequest, UpdateFlagResponse + +logger = structlog.get_logger("features") + + +class UpdateFlagAPIView(APIView): + """Update what a flag serves in an environment.""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=UpdateFlagRequest, + responses=UpdateFlagResponse, + tags=["experimental"], + description="Update the properties given, leaving the rest as they are.", + ) + def patch( + self, request: Request, environment_key: str, feature_id: int + ) -> Response: + return self._update_flag(request, environment_key, feature_id, replace=False) + + @extend_schema( + request=UpdateFlagRequest, + responses=UpdateFlagResponse, + tags=["experimental"], + description="Replace the properties given, resetting what they omit.", + ) + def put(self, request: Request, environment_key: str, feature_id: int) -> Response: + return self._update_flag(request, environment_key, feature_id, replace=True) + + def _update_flag( + self, + request: Request, + environment_key: str, + feature_id: int, + *, + replace: bool, + ) -> Response: + assert not isinstance(request.user, AnonymousUser) + + try: + environment = Environment.objects.get(api_key=environment_key) + except Environment.DoesNotExist: + raise NotFound() from None + check_update_permissions(request.user, environment, request.data) + + try: + feature = Feature.objects.get( + id=feature_id, + project_id=environment.project_id, + ) + except Feature.DoesNotExist: + raise NotFound() from None + + if environment.is_workflow_enabled: + logger.warning( + "flag.update_rejected", + organisation__id=environment.project.organisation_id, + project__id=environment.project_id, + environment__id=environment.id, + feature__id=feature.id, + reason="change_requests_enabled", + ) + raise ChangeRequestsEnabledError() + + serializer = UpdateFlagSerializer( + data=request.data, + context={"feature": feature, "replace": replace}, + ) + serializer.is_valid(raise_exception=True) + + return Response( + update_flag( + environment=environment, + feature=feature, + changes=serializer.validated_data, + replace=replace, + author=request.user, + ) + ) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 0c53d6f9c5bf..ae2bea89adb5 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() @@ -547,11 +548,15 @@ def admin_master_api_key_client(admin_master_api_key: dict) -> APIClient: # typ @pytest.fixture() -def non_admin_client(organisation, django_user_model, api_client): # type: ignore[no-untyped-def] +def non_admin_client( + django_user_model: type[FFAdminUser], + organisation: int, +) -> APIClient: user = django_user_model.objects.create(username="non_admin_user") user.add_organisation(Organisation.objects.get(id=organisation)) - api_client.force_authenticate(user=user) - return api_client + client = APIClient() + client.force_authenticate(user=user) + return client @pytest.fixture() diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 4e031c9b9285..24be750225b1 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -716,6 +716,188 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl ] +def test_update_flag__new_segment_override_without_variants__inherits_environment_default_variants( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + log: StructuredLogCapture, + mv_feature: int, + mv_feature_variants: list[int], + segment: int, + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "environment_default": { + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + } + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + ], + } + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + ).get(feature_segment__segment_id=segment) + assert dict( + override.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 25, variant_b: 25.5} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": mv_feature, + "segment_overrides__created__segment__ids": [segment], + "segment_overrides__updated__segment__ids": [], + "segment_overrides__deleted__segment__ids": [], + }, + ] + + +def test_update_flag__put_segment_override_without_variants__inherits_environment_default_variants( + admin_client: APIClient, + default_feature_value: str, + environment_api_key: str, + log: StructuredLogCapture, + mv_feature: int, + mv_feature_variants: list[int], + segment: int, + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + setup_response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "environment_default": { + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "enabled": True, + "variants": [ + {"id": variant_a, "weight": 50}, + {"id": variant_b, "weight": 0}, + ], + }, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client.put( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": variant_b, "weight": 25.5}, + ], + }, + ], + } + override = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + ).get(feature_segment__segment_id=segment) + assert dict( + override.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 25, variant_b: 25.5} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": mv_feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [segment], + "segment_overrides__deleted__segment__ids": [], + }, + ] + + def test_update_flag__put_environment_default__replaces_environment_default( admin_client: APIClient, environment_api_key: str, @@ -1424,6 +1606,45 @@ def test_update_flag__variant_omitted__responds_400( assert log.events == [] +def test_update_flag__put_environment_default_without_variants__responds_400( + admin_client: APIClient, + environment_api_key: str, + log: StructuredLogCapture, + mv_feature: int, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, variant_b = mv_feature_variants + + # When + response = admin_client.put( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "environment_default": { + "variants": ["Must include all feature's variants."], + }, + } + environment_default = FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=mv_feature, + feature_segment=None, + ).get() + assert environment_default.enabled is False + assert dict( + environment_default.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) == {variant_a: 10, variant_b: 20} + assert log.events == [] + + def test_update_flag__variant_weights_over_100__responds_400( admin_client: APIClient, environment_api_key: str, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index e527d69b1a52..99c29fa6a04c 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -264,6 +264,32 @@ Attributes: - `environment.id` - `organisation.id` +### `features.flag.update_rejected` + +Logged at `warning` from: + - `api/features/future/views.py:74` + +Attributes: + - `environment.id` + - `feature.id` + - `organisation.id` + - `project.id` + - `reason` + +### `features.flag.updated` + +Logged at `info` from: + - `api/features/future/services.py:286` + +Attributes: + - `environment.id` + - `feature.id` + - `organisation.id` + - `project.id` + - `segment_overrides.created.segment.ids` + - `segment_overrides.deleted.segment.ids` + - `segment_overrides.updated.segment.ids` + ### `gitlab.api_call.failed` Logged at `error` from: @@ -744,7 +770,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:1152` + - `api/experimentation/services.py:1147` Attributes: - `environment.id` @@ -753,8 +779,8 @@ Attributes: ### `warehouse.connection.event_names_failed` Logged at `warning` from: - - `api/experimentation/services.py:229` - - `api/experimentation/services.py:1252` + - `api/experimentation/services.py:224` + - `api/experimentation/services.py:1247` Attributes: - `environment.id` @@ -764,7 +790,7 @@ Attributes: ### `warehouse.connection.event_stats_failed` Logged at `warning` from: - - `api/experimentation/services.py:1215` + - `api/experimentation/services.py:1210` Attributes: - `environment.id` @@ -773,7 +799,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:926` + - `api/experimentation/services.py:921` Attributes: - `environment.id` @@ -782,7 +808,7 @@ Attributes: ### `warehouse.connection.verification_failed` Logged at `warning` from: - - `api/experimentation/services.py:1127` + - `api/experimentation/services.py:1122` Attributes: - `environment.id` @@ -792,7 +818,7 @@ Attributes: ### `warehouse.connection.verification_succeeded` Logged at `info` from: - - `api/experimentation/services.py:1137` + - `api/experimentation/services.py:1132` Attributes: - `environment.id` @@ -801,7 +827,7 @@ Attributes: ### `warehouse.delivery.all_objects_rejected` Logged at `error` from: - - `api/experimentation/services.py:1082` + - `api/experimentation/services.py:1077` Attributes: - `connection.id` @@ -812,7 +838,7 @@ Attributes: ### `warehouse.delivery.budget_exhausted` Logged at `info` from: - - `api/experimentation/services.py:971` + - `api/experimentation/services.py:966` Attributes: - `connection.id` @@ -823,7 +849,7 @@ Attributes: ### `warehouse.delivery.completed` Logged at `info` from: - - `api/experimentation/services.py:1092` + - `api/experimentation/services.py:1087` Attributes: - `connection.id` @@ -836,7 +862,7 @@ Attributes: ### `warehouse.delivery.failed` Logged at `error` from: - - `api/experimentation/services.py:1065` + - `api/experimentation/services.py:1060` Attributes: - `connection.id` @@ -847,7 +873,7 @@ Attributes: ### `warehouse.delivery.object_rejected` Logged at `error` from: - - `api/experimentation/services.py:1000` + - `api/experimentation/services.py:995` Attributes: - `connection.id` @@ -859,7 +885,7 @@ Attributes: ### `warehouse.srm.overallocated` Logged at `error` from: - - `api/experimentation/services.py:522` + - `api/experimentation/services.py:517` Attributes: - `environment.id` @@ -869,7 +895,7 @@ Attributes: ### `warehouse.srm.unkeyed_variant` Logged at `error` from: - - `api/experimentation/services.py:508` + - `api/experimentation/services.py:503` Attributes: - `environment.id` From eb1a49706ecb51ba5851675a8db78a104990703c Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 14:22:08 -0300 Subject: [PATCH 19/39] even more clarity --- docs/docs/managing-flags/updating-flags.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 5ef6e73ad748..36cef402f7f0 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -25,8 +25,8 @@ Learn more in the [API specification](link TODO). ## Updating a flag We support both `PATCH` and `PUT` methods for updating a flag. Both accept optional `environment_default` and -`segment_overrides` properties. Attributes omitted from a `PATCH` payload are left unchanged, while `PUT` replaces -each property it receives in full — use it with caution. +`segment_overrides` properties. Attributes omitted from a `PATCH` payload are left unchanged, while `PUT` replaces each +property it receives in full — use it with caution. Values are passed as a `value` object with a `type` and a `value` string: @@ -183,6 +183,9 @@ Overrides listed in a `PATCH` payload are added or updated by segment; overrides adding a new segment override, if `priority` is omitted, it defaults to the override's position in the `segment_overrides` list. The lowest number has the highest priority. +A new segment override serves whatever the environment default serves, until you give it a `value` of its own. An +existing override keeps its priority unless you send a new one. + ### Remove a segment override To remove a segment override, `PUT` the full list of overrides without it. `PUT` replaces the whole set, deleting any @@ -310,3 +313,6 @@ Content-Type: application/json In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the feature, even if their weight is zero, and regardless of the HTTP verb used (`PUT` or `PATCH`). + +Because `PUT` replaces `environment_default` in full, it **must** carry `variants` for a multivariate feature. A segment +override that omits `variants` inherits the weights of the environment default instead. From 14c3324feeb17adc647e03ef269731093e6c161d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 14:29:54 -0300 Subject: [PATCH 20/39] the laws of relativity don't work here --- api/features/future/mappers.py | 5 ++--- api/features/future/serializers.py | 7 +++---- api/features/future/services.py | 19 +++++++++++-------- api/features/future/views.py | 11 +++++------ .../observability/_events-catalogue.md | 4 ++-- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/api/features/future/mappers.py b/api/features/future/mappers.py index b652415d6997..fbfa513d86c0 100644 --- a/api/features/future/mappers.py +++ b/api/features/future/mappers.py @@ -1,15 +1,14 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" from features.feature_states.models import API_VALUE_TYPES -from features.models import FeatureState, FeatureStateValue - -from .types import ( +from features.future.types import ( EnvironmentDefaultResponse, FlagValue, SegmentOverrideResponse, SegmentReference, Variant, ) +from features.models import FeatureState, FeatureStateValue def map_flag_value(feature_state_value: FeatureStateValue) -> FlagValue | None: diff --git a/api/features/future/serializers.py b/api/features/future/serializers.py index 774f4136daa8..d9b631314890 100644 --- a/api/features/future/serializers.py +++ b/api/features/future/serializers.py @@ -3,16 +3,15 @@ from rest_framework import serializers from features.feature_types import MULTIVARIATE -from features.models import Feature, FeatureStateValue -from segments.models import Segment - -from .types import ( +from features.future.types import ( EnvironmentDefaultRequest, FlagValue, SegmentOverrideRequest, UpdateFlagRequest, Variant, ) +from features.models import Feature, FeatureStateValue +from segments.models import Segment class FlagValueSerializer(serializers.Serializer[FlagValue]): diff --git a/api/features/future/services.py b/api/features/future/services.py index 87e60640ea66..9d9e5953e605 100644 --- a/api/features/future/services.py +++ b/api/features/future/services.py @@ -9,20 +9,23 @@ from api_keys.user import APIKeyUser from environments.models import Environment -from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue -from features.multivariate.models import MultivariateFeatureStateValue -from features.versioning.models import EnvironmentFeatureVersion -from features.versioning.versioning_service import get_environment_flags_list -from users.models import FFAdminUser - -from .mappers import map_environment_default, map_segment_override, map_variants -from .types import ( +from features.future.mappers import ( + map_environment_default, + map_segment_override, + map_variants, +) +from features.future.types import ( EnvironmentDefaultRequest, SegmentOverrideRequest, UpdateFlagRequest, UpdateFlagResponse, Variant, ) +from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue +from features.multivariate.models import MultivariateFeatureStateValue +from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.versioning_service import get_environment_flags_list +from users.models import FFAdminUser logger = structlog.get_logger("features") diff --git a/api/features/future/views.py b/api/features/future/views.py index dbdd3c420c97..942227ffb199 100644 --- a/api/features/future/views.py +++ b/api/features/future/views.py @@ -10,14 +10,13 @@ from rest_framework.views import APIView from environments.models import Environment +from features.future.exceptions import ChangeRequestsEnabledError +from features.future.permissions import check_update_permissions +from features.future.serializers import UpdateFlagSerializer +from features.future.services import update_flag +from features.future.types import UpdateFlagRequest, UpdateFlagResponse from features.models import Feature -from .exceptions import ChangeRequestsEnabledError -from .permissions import check_update_permissions -from .serializers import UpdateFlagSerializer -from .services import update_flag -from .types import UpdateFlagRequest, UpdateFlagResponse - logger = structlog.get_logger("features") diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 99c29fa6a04c..bf81de0d7801 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -267,7 +267,7 @@ Attributes: ### `features.flag.update_rejected` Logged at `warning` from: - - `api/features/future/views.py:74` + - `api/features/future/views.py:73` Attributes: - `environment.id` @@ -279,7 +279,7 @@ Attributes: ### `features.flag.updated` Logged at `info` from: - - `api/features/future/services.py:286` + - `api/features/future/services.py:289` Attributes: - `environment.id` From 348f5a9f073de47b1119b82329eec35a719d625b Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 14 Aug 2026 17:33:13 +0000 Subject: [PATCH 21/39] chore: Update documentation artefacts --- openapi.yaml | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 9f0e3f19777a..c85ed094ddee 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12,6 +12,81 @@ servers: - url: 'https://api.flagsmith.com' description: Flagsmith API paths: + '/api/__future__/environments/{environment_key}/features/{feature_id}/': + put: + operationId: api___future___environments_features_update + description: 'Replace the properties given, resetting what they omit.' + parameters: + - name: environment_key + in: path + required: true + schema: + type: string + - name: feature_id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlagRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UpdateFlagRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/UpdateFlagRequest' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlagResponse' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - experimental + patch: + operationId: api___future___environments_features_partial_update + description: 'Update the properties given, leaving the rest as they are.' + parameters: + - name: environment_key + in: path + required: true + schema: + type: string + - name: feature_id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUpdateFlagRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUpdateFlagRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUpdateFlagRequest' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlagResponse' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - experimental '/api/experiments/environments/{environment_key}/delete-segment-override/': post: operationId: api_experiments_environments_delete_segment_override_create @@ -25402,6 +25477,17 @@ components: allOf: - $ref: '#/components/schemas/LifecycleStageEnum' readOnly: true + PatchedUpdateFlagRequest: + type: object + properties: + environment_default: + $ref: '#/components/schemas/UpdateFlagRequestEnvironmentDefaultRequest' + segment_overrides: + type: array + items: + $ref: '#/components/schemas/UpdateFlagRequestSegmentOverrideRequest' + title: Segment Overrides + title: UpdateFlagRequest PatchedUser: type: object properties: @@ -27415,6 +27501,12 @@ components: - is_admin - issuer - name + Type68aEnum: + type: string + enum: + - string + - integer + - boolean Type975Enum: description: |- * `int` - Integer @@ -27699,6 +27791,185 @@ components: - enabled - feature - value + UpdateFlagRequest: + type: object + properties: + environment_default: + $ref: '#/components/schemas/UpdateFlagRequestEnvironmentDefaultRequest' + segment_overrides: + type: array + items: + $ref: '#/components/schemas/UpdateFlagRequestSegmentOverrideRequest' + title: Segment Overrides + title: UpdateFlagRequest + UpdateFlagRequestEnvironmentDefaultRequest: + type: object + properties: + enabled: + type: boolean + title: Enabled + value: + $ref: '#/components/schemas/UpdateFlagRequestFlagValue' + variants: + type: array + items: + $ref: '#/components/schemas/UpdateFlagRequestVariant' + title: Variants + title: EnvironmentDefaultRequest + UpdateFlagRequestFlagValue: + type: object + properties: + type: + allOf: + - $ref: '#/components/schemas/Type68aEnum' + title: Type + value: + type: string + title: Value + required: + - type + - value + title: FlagValue + UpdateFlagRequestSegmentOverrideRequest: + type: object + properties: + segment: + $ref: '#/components/schemas/UpdateFlagRequestSegmentReference' + enabled: + type: boolean + title: Enabled + priority: + type: integer + title: Priority + value: + $ref: '#/components/schemas/UpdateFlagRequestFlagValue' + variants: + type: array + items: + $ref: '#/components/schemas/UpdateFlagRequestVariant' + title: Variants + required: + - segment + title: SegmentOverrideRequest + UpdateFlagRequestSegmentReference: + type: object + properties: + id: + type: integer + title: Id + required: + - id + title: SegmentReference + UpdateFlagRequestVariant: + type: object + properties: + id: + type: integer + title: Id + weight: + type: number + title: Weight + required: + - id + - weight + title: Variant + UpdateFlagResponse: + type: object + properties: + environment_default: + $ref: '#/components/schemas/UpdateFlagResponseEnvironmentDefaultResponse' + segment_overrides: + type: array + items: + $ref: '#/components/schemas/UpdateFlagResponseSegmentOverrideResponse' + title: Segment Overrides + required: + - environment_default + - segment_overrides + title: UpdateFlagResponse + UpdateFlagResponseEnvironmentDefaultResponse: + type: object + properties: + enabled: + type: boolean + title: Enabled + value: + anyOf: + - $ref: '#/components/schemas/UpdateFlagResponseFlagValue' + - type: 'null' + variants: + type: array + items: + $ref: '#/components/schemas/UpdateFlagResponseVariant' + title: Variants + required: + - enabled + - value + - variants + title: EnvironmentDefaultResponse + UpdateFlagResponseFlagValue: + type: object + properties: + type: + allOf: + - $ref: '#/components/schemas/Type68aEnum' + title: Type + value: + type: string + title: Value + required: + - type + - value + title: FlagValue + UpdateFlagResponseSegmentOverrideResponse: + type: object + properties: + segment: + $ref: '#/components/schemas/UpdateFlagResponseSegmentReference' + priority: + type: integer + title: Priority + enabled: + type: boolean + title: Enabled + value: + anyOf: + - $ref: '#/components/schemas/UpdateFlagResponseFlagValue' + - type: 'null' + variants: + type: array + items: + $ref: '#/components/schemas/UpdateFlagResponseVariant' + title: Variants + required: + - segment + - priority + - enabled + - value + - variants + title: SegmentOverrideResponse + UpdateFlagResponseSegmentReference: + type: object + properties: + id: + type: integer + title: Id + required: + - id + title: SegmentReference + UpdateFlagResponseVariant: + type: object + properties: + id: + type: integer + title: Id + weight: + type: number + title: Weight + required: + - id + - weight + title: Variant UpdateFlagV2: type: object properties: From f74048f680af53b42a7790b3805071c174c0b120 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:02:51 -0300 Subject: [PATCH 22/39] fix docs --- docs/docs/managing-flags/feature-versioning.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/docs/managing-flags/feature-versioning.md b/docs/docs/managing-flags/feature-versioning.md index 2946a51447c4..942d8702c1e2 100644 --- a/docs/docs/managing-flags/feature-versioning.md +++ b/docs/docs/managing-flags/feature-versioning.md @@ -27,7 +27,9 @@ Enabling Feature Versioning v2 on an environment is irreversible. To produce a new published version on a v2 environment, use one of: -- **The experimental [update-flag endpoints](/managing-flags/updating-flags)** (`update-flag-v1`, `update-flag-v2`, `delete-segment-override`). These accept the same payloads as on v1 environments and publish a new version per call on v2 environments. +- **The experimental [update-flag endpoint](/managing-flags/updating-flags)** (`PATCH` or `PUT` on + `/__future__/environments/{env}/features/{feature}/`). This takes the same request whichever versioning the + environment uses, and publishes a new version per call on v2 environments. - **The new versioning endpoint family**: - `GET /environments/{env}/features/{feature}/versions/` — list versions for a feature. - `POST /environments/{env}/features/{feature}/versions/` — create a draft version. From e0a0f0a9a965bdeac0ef81383e2e3bbe6c689697 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 14:46:02 -0300 Subject: [PATCH 23/39] fail safely --- api/features/feature_states/exceptions.py | 4 ++++ api/features/feature_states/models.py | 7 ++++--- api/features/future/serializers.py | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 api/features/feature_states/exceptions.py diff --git a/api/features/feature_states/exceptions.py b/api/features/feature_states/exceptions.py new file mode 100644 index 000000000000..f3b2b678998f --- /dev/null +++ b/api/features/feature_states/exceptions.py @@ -0,0 +1,4 @@ +class FeatureValueError(ValueError): + """Raised when a feature value is invalid.""" + + pass diff --git a/api/features/feature_states/models.py b/api/features/feature_states/models.py index ab69177bf144..3f3895ae9b6f 100644 --- a/api/features/feature_states/models.py +++ b/api/features/feature_states/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models +from features.feature_states.exceptions import FeatureValueError from features.value_types import ( BOOLEAN, FEATURE_STATE_VALUE_TYPES, @@ -60,19 +61,19 @@ def set_value(self, value: str, type_: FeatureValueType) -> None: try: typed_value = int(value) except ValueError: - raise ValueError(f"'{value}' is not a valid integer") + raise FeatureValueError(f"'{value}' is not a valid integer") field = "integer_value" type_const = INTEGER case "boolean": if value.lower() not in ("true", "false"): - raise ValueError( + raise FeatureValueError( f"'{value}' is not a valid boolean (use 'true' or 'false')" ) typed_value = value.lower() == "true" field = "boolean_value" type_const = BOOLEAN case _: - raise ValueError( + raise FeatureValueError( f"'{type_}' is not a valid type (use 'string', 'integer', or 'boolean')" ) diff --git a/api/features/future/serializers.py b/api/features/future/serializers.py index d9b631314890..30db2bf8f2d3 100644 --- a/api/features/future/serializers.py +++ b/api/features/future/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers +from features.feature_states.exceptions import FeatureValueError from features.feature_types import MULTIVARIATE from features.future.types import ( EnvironmentDefaultRequest, @@ -52,8 +53,8 @@ class FlagStateSerializer(serializers.Serializer[dict[str, object]]): def validate_value(self, value: FlagValue) -> FlagValue: try: FeatureStateValue().set_value(value["value"], value["type"]) - except ValueError as exc: - raise serializers.ValidationError(str(exc)) from exc + except FeatureValueError as error: + raise serializers.ValidationError(str(error)) from error return value def validate_variants(self, variants: list[Variant]) -> list[Variant]: From 89b7b85bfa84f185622f10020f75f7ebcee86b3b Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:13:33 -0300 Subject: [PATCH 24/39] almost ignorable edge case --- api/features/future/permissions.py | 4 ++-- api/features/future/views.py | 7 +++++- .../future/test_update_flag_endpoint.py | 22 +++++++++++++++++++ .../observability/_events-catalogue.md | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/api/features/future/permissions.py b/api/features/future/permissions.py index 4b01dd305188..1bf4fae3c0ab 100644 --- a/api/features/future/permissions.py +++ b/api/features/future/permissions.py @@ -1,6 +1,6 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" -from collections.abc import Container +from collections.abc import Mapping from common.environments.permissions import ( MANAGE_SEGMENT_OVERRIDES, @@ -18,7 +18,7 @@ def check_update_permissions( - user: UserABC, environment: Environment, properties: Container[str] + user: UserABC, environment: Environment, properties: Mapping[str, object] ) -> None: """Authorise a caller to write the flag properties they sent. diff --git a/api/features/future/views.py b/api/features/future/views.py index 942227ffb199..4927eba4e884 100644 --- a/api/features/future/views.py +++ b/api/features/future/views.py @@ -1,9 +1,11 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" +from collections.abc import Mapping + import structlog from django.contrib.auth.models import AnonymousUser from drf_spectacular.utils import extend_schema -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -55,6 +57,9 @@ def _update_flag( ) -> Response: assert not isinstance(request.user, AnonymousUser) + if not isinstance(request.data, Mapping): + raise ValidationError("Expected an object.") + try: environment = Environment.objects.get(api_key=environment_key) except Environment.DoesNotExist: diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 24be750225b1..d9d9ce0f6b51 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -1220,6 +1220,28 @@ def test_update_flag__value_not_matching_type__responds_400( assert log.events == [] +@pytest.mark.parametrize("body", [[], "segment_overrides"]) +def test_update_flag__body_is_not_an_object__responds_400( + admin_client: APIClient, + body: object, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + body, + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == ["Expected an object."] + assert log.events == [] + + def test_update_flag__unknown_feature__responds_404( admin_client: APIClient, environment_api_key: str, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index bf81de0d7801..8610a7811f4e 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -267,7 +267,7 @@ Attributes: ### `features.flag.update_rejected` Logged at `warning` from: - - `api/features/future/views.py:73` + - `api/features/future/views.py:78` Attributes: - `environment.id` From ee2c22fca71d4583177db927b4d49de4e503bb78 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:23:56 -0300 Subject: [PATCH 25/39] yes it works --- api/tests/integration/conftest.py | 1 + .../features/future/test_update_flag_endpoint.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index ae2bea89adb5..ac6174e4d7e6 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -253,6 +253,7 @@ def feature( "project": project, }, ) + assert response.status_code == status.HTTP_201_CREATED return int(response.json()["id"]) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index d9d9ce0f6b51..e91cdf6da842 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -42,6 +42,7 @@ def segment_2( }, format="json", ) + assert response.status_code == 201 return int(response.json()["id"]) @@ -50,11 +51,13 @@ def segment_in_other_project( admin_client: APIClient, organisation: int, ) -> int: - other_project = admin_client.post( + project_response = admin_client.post( "/api/v1/projects/", {"name": "Other Project", "organisation": organisation}, format="json", - ).json()["id"] + ) + assert project_response.status_code == 201 + other_project = project_response.json()["id"] response = admin_client.post( f"/api/v1/projects/{other_project}/segments/", { @@ -64,6 +67,7 @@ def segment_in_other_project( }, format="json", ) + assert response.status_code == 201 return int(response.json()["id"]) From e49f75720c96be6edc6fddcdd0baf512daf60121 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:27:14 -0300 Subject: [PATCH 26/39] DRY --- api/features/future/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/features/future/serializers.py b/api/features/future/serializers.py index 30db2bf8f2d3..b9a8ee046ba9 100644 --- a/api/features/future/serializers.py +++ b/api/features/future/serializers.py @@ -1,8 +1,11 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" +from typing import get_args + from rest_framework import serializers from features.feature_states.exceptions import FeatureValueError +from features.feature_states.models import FeatureValueType from features.feature_types import MULTIVARIATE from features.future.types import ( EnvironmentDefaultRequest, @@ -18,7 +21,7 @@ class FlagValueSerializer(serializers.Serializer[FlagValue]): """A flag value, typed by the caller so it survives the round trip as a string.""" - type = serializers.ChoiceField(choices=["string", "integer", "boolean"]) + type = serializers.ChoiceField(choices=get_args(FeatureValueType)) value = serializers.CharField(allow_blank=True) From 9ebb0c341c005776ae534fb529a7a8a3d612a71f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:45:41 -0300 Subject: [PATCH 27/39] works for both auth types --- .../future/test_update_flag_endpoint.py | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index e91cdf6da842..055fb925de6d 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -90,7 +90,7 @@ def mv_feature_variants( def test_update_flag__patch_environment_default_enabled__toggles_flag( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -106,7 +106,7 @@ def test_update_flag__patch_environment_default_enabled__toggles_flag( assert environment_default.enabled is False # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -144,14 +144,14 @@ def test_update_flag__patch_environment_default_enabled__toggles_flag( def test_update_flag__patch_environment_default_value__updates_value( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"environment_default": {"value": {"type": "integer", "value": "1000"}}} @@ -191,7 +191,7 @@ def test_update_flag__patch_environment_default_value__updates_value( def test_update_flag__patch_environment_default_variants__reweights_variants( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, log: StructuredLogCapture, @@ -203,7 +203,7 @@ def test_update_flag__patch_environment_default_variants__reweights_variants( variant_a, variant_b = mv_feature_variants # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -257,7 +257,7 @@ def test_update_flag__patch_environment_default_variants__reweights_variants( def test_update_flag__patch_segment_override_enabled__creates_override( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -266,7 +266,7 @@ def test_update_flag__patch_segment_override_enabled__creates_override( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} @@ -316,7 +316,7 @@ def test_update_flag__patch_segment_override_enabled__creates_override( def test_update_flag__patch_segment_override_value__overrides_value( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -325,7 +325,7 @@ def test_update_flag__patch_segment_override_value__overrides_value( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -388,7 +388,7 @@ def test_update_flag__patch_segment_override_value__overrides_value( def test_update_flag__patch_segment_override_without_value__inherits_environment_default_value( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -396,7 +396,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"environment_default": {"value": {"type": "string", "value": "control"}}} @@ -407,7 +407,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment log.events.clear() # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} @@ -454,7 +454,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment def test_update_flag__patch_segment_overrides_without_priority__sets_priority_from_position( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -464,7 +464,7 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -484,7 +484,7 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr log.events.clear() # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -545,7 +545,7 @@ def test_update_flag__patch_segment_overrides_without_priority__sets_priority_fr def test_update_flag__patch_segment_override_priority__writes_priority_as_given( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -555,7 +555,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -575,7 +575,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( log.events.clear() # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"segment_overrides": [{"segment": {"id": segment}, "priority": 1}]} @@ -632,7 +632,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( def test_update_flag__patch_segment_override_variants__reweights_for_segment_only( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, log: StructuredLogCapture, @@ -645,7 +645,7 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl variant_a, variant_b = mv_feature_variants # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -721,7 +721,7 @@ def test_update_flag__patch_segment_override_variants__reweights_for_segment_onl def test_update_flag__new_segment_override_without_variants__inherits_environment_default_variants( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, log: StructuredLogCapture, @@ -732,7 +732,7 @@ def test_update_flag__new_segment_override_without_variants__inherits_environmen ) -> None: # Given variant_a, variant_b = mv_feature_variants - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -750,7 +750,7 @@ def test_update_flag__new_segment_override_without_variants__inherits_environmen log.events.clear() # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} @@ -807,7 +807,7 @@ def test_update_flag__new_segment_override_without_variants__inherits_environmen def test_update_flag__put_segment_override_without_variants__inherits_environment_default_variants( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, log: StructuredLogCapture, @@ -818,7 +818,7 @@ def test_update_flag__put_segment_override_without_variants__inherits_environmen ) -> None: # Given variant_a, variant_b = mv_feature_variants - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -846,7 +846,7 @@ def test_update_flag__put_segment_override_without_variants__inherits_environmen log.events.clear() # When - response = admin_client.put( + response = admin_client_new.put( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} @@ -903,14 +903,14 @@ def test_update_flag__put_segment_override_without_variants__inherits_environmen def test_update_flag__put_environment_default__replaces_environment_default( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -926,7 +926,7 @@ def test_update_flag__put_environment_default__replaces_environment_default( log.events.clear() # When - response = admin_client.put( + response = admin_client_new.put( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -965,7 +965,7 @@ def test_update_flag__put_environment_default__replaces_environment_default( def test_update_flag__put_segment_overrides__replaces_segment_overrides( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -974,7 +974,7 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -994,7 +994,7 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( log.events.clear() # When - response = admin_client.put( + response = admin_client_new.put( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1057,7 +1057,7 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( def test_update_flag__put_environment_default_and_segment_overrides__replaces_both( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1066,7 +1066,7 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo versioned_environment: Environment, ) -> None: # Given - setup_response = admin_client.patch( + setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1085,7 +1085,7 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo log.events.clear() # When - response = admin_client.put( + response = admin_client_new.put( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1152,7 +1152,7 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo def test_update_flag__change_requests_enabled__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1163,7 +1163,7 @@ def test_update_flag__change_requests_enabled__responds_400( versioned_environment.save() # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -1194,7 +1194,7 @@ def test_update_flag__change_requests_enabled__responds_400( def test_update_flag__value_not_matching_type__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, default_feature_value: str, environment_api_key: str, feature: int, @@ -1202,7 +1202,7 @@ def test_update_flag__value_not_matching_type__responds_400( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"environment_default": {"value": {"type": "integer", "value": "abc"}}} @@ -1226,7 +1226,7 @@ def test_update_flag__value_not_matching_type__responds_400( @pytest.mark.parametrize("body", [[], "segment_overrides"]) def test_update_flag__body_is_not_an_object__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, body: object, environment_api_key: str, feature: int, @@ -1234,7 +1234,7 @@ def test_update_flag__body_is_not_an_object__responds_400( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", body, format="json", @@ -1247,7 +1247,7 @@ def test_update_flag__body_is_not_an_object__responds_400( def test_update_flag__unknown_feature__responds_404( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1256,7 +1256,7 @@ def test_update_flag__unknown_feature__responds_404( unknown_feature = feature + 1 # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{unknown_feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -1269,12 +1269,12 @@ def test_update_flag__unknown_feature__responds_404( def test_update_flag__unknown_environment__responds_404( - admin_client: APIClient, + admin_client_new: APIClient, feature: int, log: StructuredLogCapture, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/unknown-api-key/features/{feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -1313,7 +1313,7 @@ def test_update_flag__user_without_environment_permissions__responds_404( def test_update_flag__unknown_segment__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1324,7 +1324,7 @@ def test_update_flag__unknown_segment__responds_400( unknown_segment = segment + 1 # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1353,7 +1353,7 @@ def test_update_flag__unknown_segment__responds_400( def test_update_flag__duplicate_segment_overrides__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1361,7 +1361,7 @@ def test_update_flag__duplicate_segment_overrides__responds_400( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1391,7 +1391,7 @@ def test_update_flag__duplicate_segment_overrides__responds_400( def test_update_flag__segment_from_another_project__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1399,7 +1399,7 @@ def test_update_flag__segment_from_another_project__responds_400( versioned_environment: Environment, ) -> None: # Given / When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( { @@ -1545,7 +1545,7 @@ def test_update_flag__manage_segment_overrides_permission__gates_segment_overrid def test_update_flag__unknown_variant__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, log: StructuredLogCapture, mv_feature: int, @@ -1557,7 +1557,7 @@ def test_update_flag__unknown_variant__responds_400( unknown_variant = variant_b + 1 # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -1593,7 +1593,7 @@ def test_update_flag__unknown_variant__responds_400( def test_update_flag__variant_omitted__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, log: StructuredLogCapture, mv_feature: int, @@ -1604,7 +1604,7 @@ def test_update_flag__variant_omitted__responds_400( variant_a, variant_b = mv_feature_variants # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( {"environment_default": {"variants": [{"id": variant_a, "weight": 30}]}} @@ -1633,7 +1633,7 @@ def test_update_flag__variant_omitted__responds_400( def test_update_flag__put_environment_default_without_variants__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, log: StructuredLogCapture, mv_feature: int, @@ -1644,7 +1644,7 @@ def test_update_flag__put_environment_default_without_variants__responds_400( variant_a, variant_b = mv_feature_variants # When - response = admin_client.put( + response = admin_client_new.put( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest({"environment_default": {"enabled": True}}), format="json", @@ -1672,7 +1672,7 @@ def test_update_flag__put_environment_default_without_variants__responds_400( def test_update_flag__variant_weights_over_100__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, log: StructuredLogCapture, mv_feature: int, @@ -1683,7 +1683,7 @@ def test_update_flag__variant_weights_over_100__responds_400( variant_a, variant_b = mv_feature_variants # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", UpdateFlagRequest( { @@ -1717,7 +1717,7 @@ def test_update_flag__variant_weights_over_100__responds_400( def test_update_flag__variants_on_standard_feature__responds_400( - admin_client: APIClient, + admin_client_new: APIClient, environment_api_key: str, feature: int, log: StructuredLogCapture, @@ -1728,7 +1728,7 @@ def test_update_flag__variants_on_standard_feature__responds_400( variant_a, _ = mv_feature_variants # When - response = admin_client.patch( + response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( {"environment_default": {"variants": [{"id": variant_a, "weight": 10}]}} From dfe720d6f94c0dc452c3b70ca84c84b0030ac6a6 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:46:26 -0300 Subject: [PATCH 28/39] py3.11 is still here --- api/features/future/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/features/future/types.py b/api/features/future/types.py index 6374963b3f67..4a7702a4effc 100644 --- a/api/features/future/types.py +++ b/api/features/future/types.py @@ -1,7 +1,8 @@ """https://docs.flagsmith.com/managing-flags/updating-flags""" from collections.abc import Sequence -from typing import NotRequired, TypedDict + +from typing_extensions import NotRequired, TypedDict from features.feature_states.models import FeatureValueType From 36b9208770464e763f61a6c61570da437d332c8a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:54:29 -0300 Subject: [PATCH 29/39] fix docs link --- docs/docs/managing-flags/updating-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 36cef402f7f0..df3154dc5ca3 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -20,7 +20,7 @@ These may be lifted in the future. ::: -Learn more in the [API specification](link TODO). +Learn more in the [API specification](https://api.flagsmith.com/api/v1/docs/#/experimental). ## Updating a flag From ce77df32f9da54351f7a2326fda9e1301ebf73ba Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 15:54:41 -0300 Subject: [PATCH 30/39] fix contradiction --- docs/docs/managing-flags/updating-flags.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index df3154dc5ca3..332978cc4ca2 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -311,8 +311,9 @@ Content-Type: application/json } ``` -In both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants for the -feature, even if their weight is zero, and regardless of the HTTP verb used (`PUT` or `PATCH`). +When present, in both `environment_default` and `segment_overrides`, the `variants` list **must** include all variants +for the feature, even if their weight is zero. Because `PUT` replaces `environment_default` in full, it **must** carry `variants` for a multivariate feature. A segment -override that omits `variants` inherits the weights of the environment default instead. +override that omits `variants` inherits the weights of the environment default, unless `PATCH` is updating an existing +override, which keeps its own weights. From 2f070b6ed14f17cf6187789e9604d61d95af9600 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 16:26:20 -0300 Subject: [PATCH 31/39] get priorities straight (again) --- api/features/future/exceptions.py | 7 + api/features/future/services.py | 28 ++- .../future/test_update_flag_endpoint.py | 169 +++++++++++++++++- docs/docs/managing-flags/updating-flags.md | 6 +- 4 files changed, 201 insertions(+), 9 deletions(-) diff --git a/api/features/future/exceptions.py b/api/features/future/exceptions.py index e86dd59b73f6..9593f226827c 100644 --- a/api/features/future/exceptions.py +++ b/api/features/future/exceptions.py @@ -11,3 +11,10 @@ class ChangeRequestsEnabledError(APIException): default_detail = ( "Cannot update flags in an environment with change requests enabled." ) + + +class DuplicatePriorityError(APIException): + """Raised where a flag's segment overrides would end up sharing a priority.""" + + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Segment overrides must not share a priority." diff --git a/api/features/future/services.py b/api/features/future/services.py index 9d9e5953e605..c2be94427cd2 100644 --- a/api/features/future/services.py +++ b/api/features/future/services.py @@ -5,10 +5,11 @@ import structlog from django.db import transaction -from django.db.models import Q +from django.db.models import Count, Q from api_keys.user import APIKeyUser from environments.models import Environment +from features.future.exceptions import DuplicatePriorityError from features.future.mappers import ( map_environment_default, map_segment_override, @@ -186,6 +187,29 @@ def _delete_segment_overrides( feature_segments.filter(segment_id__in=segment_ids).delete() +def _check_priorities( + environment: Environment, + feature: Feature, + version: EnvironmentFeatureVersion | None, +) -> None: + """Precedence between two segment overrides sharing a priority is undefined.""" + duplicate = ( + FeatureSegment.objects.filter( + environment=environment, + feature=feature, + environment_feature_version=version, + ) + .values("priority") + .annotate(count=Count("priority")) + .filter(count__gt=1) + .order_by("priority") + .values_list("priority", flat=True) + .first() + ) + if duplicate is not None: + raise DuplicatePriorityError(f"Duplicate priority: {duplicate}.") + + class WrittenSegmentOverrides(NamedTuple): created: list[int] updated: list[int] @@ -235,6 +259,8 @@ def _write_segment_overrides( environment_default=environment_default, ) + _check_priorities(environment, feature, version) + return written diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 055fb925de6d..20f1010e7963 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -578,7 +578,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( - {"segment_overrides": [{"segment": {"id": segment}, "priority": 1}]} + {"segment_overrides": [{"segment": {"id": segment}, "priority": 5}]} ), format="json", ) @@ -593,15 +593,15 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( }, "segment_overrides": [ { - "segment": {"id": segment}, + "segment": {"id": segment_2}, "priority": 1, "enabled": True, "value": {"type": "string", "value": default_feature_value}, "variants": [], }, { - "segment": {"id": segment_2}, - "priority": 1, + "segment": {"id": segment}, + "priority": 5, "enabled": True, "value": {"type": "string", "value": default_feature_value}, "variants": [], @@ -615,7 +615,7 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( ) .exclude(feature_segment=None) .values_list("feature_segment__segment_id", "feature_segment__priority") - ) == {segment: 1, segment_2: 1} + ) == {segment: 5, segment_2: 1} assert log.events == [ { "level": "info", @@ -631,6 +631,165 @@ def test_update_flag__patch_segment_override_priority__writes_priority_as_given( ] +def test_update_flag__patch_segment_override_priority_in_use__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "priority": 1}]} + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == {"detail": "Duplicate priority: 1."} + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .values_list("feature_segment__segment_id", "feature_segment__priority") + ) == {segment: 0, segment_2: 1} + assert log.events == [] + + +def test_update_flag__patch_second_segment_override_without_priority__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + {"segment_overrides": [{"segment": {"id": segment_2}, "enabled": True}]} + ), + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == {"detail": "Duplicate priority: 0."} + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .values_list("feature_segment__segment_id", "feature_segment__priority") + ) == {segment: 0} + assert log.events == [] + + +def test_update_flag__patch_segment_overrides_swapping_priorities__reorders_overrides( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + segment_2: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment}, "enabled": True}, + {"segment": {"id": segment_2}, "enabled": True}, + ], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + {"segment": {"id": segment_2}, "priority": 0}, + {"segment": {"id": segment}, "priority": 1}, + ], + } + ), + format="json", + ) + + # Then + assert response.status_code == 200 + assert [ + (override["segment"]["id"], override["priority"]) + for override in response.json()["segment_overrides"] + ] == [(segment_2, 0), (segment, 1)] + assert dict( + FeatureState.objects.get_live_feature_states( + environment=versioned_environment, + feature_id=feature, + ) + .exclude(feature_segment=None) + .values_list("feature_segment__segment_id", "feature_segment__priority") + ) == {segment: 1, segment_2: 0} + assert log.events == [ + { + "level": "info", + "event": "flag.updated", + "organisation__id": versioned_environment.project.organisation_id, + "project__id": versioned_environment.project_id, + "environment__id": versioned_environment.id, + "feature__id": feature, + "segment_overrides__created__segment__ids": [], + "segment_overrides__updated__segment__ids": [segment_2, segment], + "segment_overrides__deleted__segment__ids": [], + }, + ] + + def test_update_flag__patch_segment_override_variants__reweights_for_segment_only( admin_client_new: APIClient, default_feature_value: str, diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 332978cc4ca2..33f0fc0e4a1a 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -181,10 +181,10 @@ Content-Type: application/json Overrides listed in a `PATCH` payload are added or updated by segment; overrides not listed are left unchanged. When adding a new segment override, if `priority` is omitted, it defaults to the override's position in the -`segment_overrides` list. The lowest number has the highest priority. +`segment_overrides` list. An existing override keeps its priority unless you send a new one. The lowest number has the +highest priority, and no two overrides may share one: a request that would leave two on the same number is rejected. -A new segment override serves whatever the environment default serves, until you give it a `value` of its own. An -existing override keeps its priority unless you send a new one. +A new segment override serves whatever the environment default serves, until you give it a `value` of its own. ### Remove a segment override From 7da1e6bf23f957dbd03207539f6bfdf70e0b3198 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 16:27:00 -0300 Subject: [PATCH 32/39] fix flag state inheritance --- api/features/future/services.py | 2 +- .../future/test_update_flag_endpoint.py | 18 +++++++++++------- docs/docs/managing-flags/updating-flags.md | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/api/features/future/services.py b/api/features/future/services.py index c2be94427cd2..40df44c55734 100644 --- a/api/features/future/services.py +++ b/api/features/future/services.py @@ -132,7 +132,7 @@ def _write_segment_override( feature_segment.save(update_fields=["priority"]) if replace or "enabled" in changes: - feature_state.enabled = changes.get("enabled", False) + feature_state.enabled = changes.get("enabled", environment_default.enabled) feature_state.save(update_fields=["enabled"]) feature_state_value = feature_state.feature_state_value diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 20f1010e7963..1cbdef74ca80 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -387,7 +387,7 @@ def test_update_flag__patch_segment_override_value__overrides_value( ] -def test_update_flag__patch_segment_override_without_value__inherits_environment_default_value( +def test_update_flag__new_segment_override_without_state__inherits_environment_default_state( admin_client_new: APIClient, environment_api_key: str, feature: int, @@ -399,7 +399,12 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment setup_response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", UpdateFlagRequest( - {"environment_default": {"value": {"type": "string", "value": "control"}}} + { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": "control"}, + }, + } ), format="json", ) @@ -409,9 +414,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment # When response = admin_client_new.patch( f"/api/__future__/environments/{environment_api_key}/features/{feature}/", - UpdateFlagRequest( - {"segment_overrides": [{"segment": {"id": segment}, "enabled": True}]} - ), + UpdateFlagRequest({"segment_overrides": [{"segment": {"id": segment}}]}), format="json", ) @@ -419,7 +422,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment assert response.status_code == 200 assert response.json() == { "environment_default": { - "enabled": False, + "enabled": True, "value": {"type": "string", "value": "control"}, "variants": [], }, @@ -437,6 +440,7 @@ def test_update_flag__patch_segment_override_without_value__inherits_environment environment=versioned_environment, feature_id=feature, ).get(feature_segment__segment_id=segment) + assert override.enabled is True assert override.get_feature_state_value() == "control" assert log.events == [ { @@ -1181,7 +1185,7 @@ def test_update_flag__put_segment_overrides__replaces_segment_overrides( { "segment": {"id": segment}, "priority": 10, - "enabled": False, + "enabled": True, "value": {"type": "string", "value": "enterprise"}, "variants": [], }, diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 33f0fc0e4a1a..866966d073dc 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -184,7 +184,8 @@ adding a new segment override, if `priority` is omitted, it defaults to the over `segment_overrides` list. An existing override keeps its priority unless you send a new one. The lowest number has the highest priority, and no two overrides may share one: a request that would leave two on the same number is rejected. -A new segment override serves whatever the environment default serves, until you give it a `value` of its own. +A segment override serves whatever the environment default serves — `enabled`, `value` and `variants` alike — until you +give it its own. `PUT` restores that for whatever it omits. ### Remove a segment override From dfc9da6197a2c778cb9b83a07d6673e0709ce64f Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 14 Aug 2026 19:29:04 +0000 Subject: [PATCH 33/39] chore: Update documentation artefacts --- .../deployment-self-hosting/observability/_events-catalogue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 8610a7811f4e..7857b191f10a 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -279,7 +279,7 @@ Attributes: ### `features.flag.updated` Logged at `info` from: - - `api/features/future/services.py:289` + - `api/features/future/services.py:315` Attributes: - `environment.id` From 1304243efd10f00995876f52a12335dc26251ddd Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 17:34:19 -0300 Subject: [PATCH 34/39] why not a get --- api/api/urls/future.py | 6 +- api/features/future/permissions.py | 15 +++ api/features/future/views.py | 55 +++++++---- .../future/test_update_flag_endpoint.py | 95 +++++++++++++++++++ docs/docs/managing-flags/updating-flags.md | 23 ++++- 5 files changed, 173 insertions(+), 21 deletions(-) diff --git a/api/api/urls/future.py b/api/api/urls/future.py index 0a1d14b4e5bd..7fd42673cfe0 100644 --- a/api/api/urls/future.py +++ b/api/api/urls/future.py @@ -7,14 +7,14 @@ from django.urls import path -from features.future.views import UpdateFlagAPIView +from features.future.views import FlagAPIView app_name = "future" urlpatterns = [ path( "environments//features//", - UpdateFlagAPIView.as_view(), - name="update-flag", + FlagAPIView.as_view(), + name="flag", ), ] diff --git a/api/features/future/permissions.py b/api/features/future/permissions.py index 1bf4fae3c0ab..6a9d84dc03fd 100644 --- a/api/features/future/permissions.py +++ b/api/features/future/permissions.py @@ -5,6 +5,7 @@ from common.environments.permissions import ( MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, ) from rest_framework.exceptions import NotFound, PermissionDenied @@ -16,6 +17,20 @@ "segment_overrides": MANAGE_SEGMENT_OVERRIDES, } +READ_PERMISSIONS = [VIEW_ENVIRONMENT, *PROPERTY_PERMISSIONS.values()] + + +def check_read_permissions(user: UserABC, environment: Environment) -> None: + """Authorise a caller to read a flag. + + A caller who may neither view nor write the environment is not told it exists. + """ + if not any( + user.has_environment_permission(permission, environment) + for permission in READ_PERMISSIONS + ): + raise NotFound() + def check_update_permissions( user: UserABC, environment: Environment, properties: Mapping[str, object] diff --git a/api/features/future/views.py b/api/features/future/views.py index 4927eba4e884..612f3d389ec0 100644 --- a/api/features/future/views.py +++ b/api/features/future/views.py @@ -13,20 +13,53 @@ from environments.models import Environment from features.future.exceptions import ChangeRequestsEnabledError -from features.future.permissions import check_update_permissions +from features.future.permissions import ( + check_read_permissions, + check_update_permissions, +) from features.future.serializers import UpdateFlagSerializer -from features.future.services import update_flag +from features.future.services import get_flag, update_flag from features.future.types import UpdateFlagRequest, UpdateFlagResponse from features.models import Feature logger = structlog.get_logger("features") -class UpdateFlagAPIView(APIView): - """Update what a flag serves in an environment.""" +def _get_environment(environment_key: str) -> Environment: + try: + return Environment.objects.get(api_key=environment_key) # type: ignore[no-any-return] + except Environment.DoesNotExist: + raise NotFound() from None + + +def _get_feature(environment: Environment, feature_id: int) -> Feature: + try: + return Feature.objects.get( # type: ignore[no-any-return] + id=feature_id, project_id=environment.project_id + ) + except Feature.DoesNotExist: + raise NotFound() from None + + +class FlagAPIView(APIView): + """Read or update what a flag serves in an environment.""" permission_classes = [IsAuthenticated] + @extend_schema( + responses=UpdateFlagResponse, + tags=["experimental"], + description="Read what the flag serves in the environment.", + ) + def get(self, request: Request, environment_key: str, feature_id: int) -> Response: + assert not isinstance(request.user, AnonymousUser) + + environment = _get_environment(environment_key) + check_read_permissions(request.user, environment) + feature = _get_feature(environment, feature_id) + + return Response(get_flag(environment=environment, feature=feature)) + @extend_schema( request=UpdateFlagRequest, responses=UpdateFlagResponse, @@ -60,19 +93,9 @@ def _update_flag( if not isinstance(request.data, Mapping): raise ValidationError("Expected an object.") - try: - environment = Environment.objects.get(api_key=environment_key) - except Environment.DoesNotExist: - raise NotFound() from None + environment = _get_environment(environment_key) check_update_permissions(request.user, environment, request.data) - - try: - feature = Feature.objects.get( - id=feature_id, - project_id=environment.project_id, - ) - except Feature.DoesNotExist: - raise NotFound() from None + feature = _get_feature(environment, feature_id) if environment.is_workflow_enabled: logger.warning( diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_update_flag_endpoint.py index 1cbdef74ca80..cd851da791e1 100644 --- a/api/tests/integration/features/future/test_update_flag_endpoint.py +++ b/api/tests/integration/features/future/test_update_flag_endpoint.py @@ -89,6 +89,101 @@ def mv_feature_variants( ] +def test_get_flag__user_authorised__returns_flag_state( + admin_client_new: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, +) -> None: + # Given + setup_response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest( + { + "environment_default": {"enabled": True}, + "segment_overrides": [{"segment": {"id": segment}, "priority": 3}], + } + ), + format="json", + ) + assert setup_response.status_code == 200 + log.events.clear() + + # When + response = admin_client_new.get( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 3, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + ], + } + assert log.events == [] + + +def test_get_flag__change_requests_enabled__returns_flag_state( + admin_client_new: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given + versioned_environment.minimum_change_request_approvals = 2 + versioned_environment.save() + + # When + response = admin_client_new.get( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [], + } + assert log.events == [] + + +def test_get_flag__user_not_authorised__responds_404( + non_admin_client: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given / When + response = non_admin_client.get( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + ) + + # Then + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + assert log.events == [] + + def test_update_flag__patch_environment_default_enabled__toggles_flag( admin_client_new: APIClient, default_feature_value: str, diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md index 866966d073dc..c131f45626ca 100644 --- a/docs/docs/managing-flags/updating-flags.md +++ b/docs/docs/managing-flags/updating-flags.md @@ -12,8 +12,8 @@ eventually make them canonical. **These endpoints are experimental and may change without notice.** Note these limitations: -- They cannot be used when [change requests](/administration-and-security/governance-and-compliance/change-requests) are - enabled. +- Flags cannot be updated when [change requests](/administration-and-security/governance-and-compliance/change-requests) + are enabled. Reading them still works. - They do not support identity overrides. These may be lifted in the future. @@ -22,6 +22,25 @@ These may be lifted in the future. Learn more in the [API specification](https://api.flagsmith.com/api/v1/docs/#/experimental). +## Reading a flag + +`GET` returns everything the flag serves in an environment, in the same shape the update methods respond with: + +```bash +curl 'https://api.flagsmith.com/api/__future__/environments/{environment_key}/features/{feature_id}/' \ + -H 'Authorization: Api-Key {api_key}' +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "environment_default": {"enabled": true, "value": {"type": "string", "value": "hello"}, "variants": []}, + "segment_overrides": [] +} +``` + ## Updating a flag We support both `PATCH` and `PUT` methods for updating a flag. Both accept optional `environment_default` and From 6531905b7fae1b0ae820ebe84525d7b5108f1310 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 17:38:26 -0300 Subject: [PATCH 35/39] better name now --- .../{test_update_flag_endpoint.py => test_flag_endpoint.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename api/tests/integration/features/future/{test_update_flag_endpoint.py => test_flag_endpoint.py} (100%) diff --git a/api/tests/integration/features/future/test_update_flag_endpoint.py b/api/tests/integration/features/future/test_flag_endpoint.py similarity index 100% rename from api/tests/integration/features/future/test_update_flag_endpoint.py rename to api/tests/integration/features/future/test_flag_endpoint.py From 05b52cfc4fcf1d6f1e651546bb1bb028c45cefd1 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 14 Aug 2026 20:40:21 +0000 Subject: [PATCH 36/39] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 2 +- openapi.yaml | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 7857b191f10a..7f11bb0b8e2d 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -267,7 +267,7 @@ Attributes: ### `features.flag.update_rejected` Logged at `warning` from: - - `api/features/future/views.py:78` + - `api/features/future/views.py:101` Attributes: - `environment.id` diff --git a/openapi.yaml b/openapi.yaml index c85ed094ddee..b65e065590eb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -13,6 +13,32 @@ servers: description: Flagsmith API paths: '/api/__future__/environments/{environment_key}/features/{feature_id}/': + get: + operationId: api___future___environments_features_retrieve + description: Read what the flag serves in the environment. + parameters: + - name: environment_key + in: path + required: true + schema: + type: string + - name: feature_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlagResponse' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - experimental put: operationId: api___future___environments_features_update description: 'Replace the properties given, resetting what they omit.' From 9d640b545803f543b7d5a4db27430eb97794fc4d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 17:53:12 -0300 Subject: [PATCH 37/39] no --allow-empty here --- api/features/future/services.py | 4 ++ .../features/future/test_flag_endpoint.py | 50 +++++++++++++++++++ .../observability/_events-catalogue.md | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/api/features/future/services.py b/api/features/future/services.py index 40df44c55734..30bb62eaa6c2 100644 --- a/api/features/future/services.py +++ b/api/features/future/services.py @@ -273,6 +273,10 @@ def update_flag( author: FFAdminUser | APIKeyUser, ) -> UpdateFlagResponse: """Write the given parts of a flag, whichever versioning the environment uses.""" + writes_nothing = not changes if replace else not any(changes.values()) + if writes_nothing: + return get_flag(environment=environment, feature=feature) + written = WrittenSegmentOverrides([], [], []) with transaction.atomic(): diff --git a/api/tests/integration/features/future/test_flag_endpoint.py b/api/tests/integration/features/future/test_flag_endpoint.py index cd851da791e1..444f7888f64a 100644 --- a/api/tests/integration/features/future/test_flag_endpoint.py +++ b/api/tests/integration/features/future/test_flag_endpoint.py @@ -11,6 +11,7 @@ from environments.models import Environment from features.future.types import UpdateFlagRequest from features.models import FeatureState +from features.versioning.models import EnvironmentFeatureVersion from features.versioning.tasks import enable_v2_versioning from organisations.models import Organisation from tests.integration.helpers import create_mv_option_with_api @@ -89,6 +90,55 @@ def mv_feature_variants( ] +@pytest.mark.parametrize( + "changes", + [ + pytest.param(UpdateFlagRequest({}), id="no_properties"), + pytest.param( + UpdateFlagRequest({"environment_default": {}}), + id="empty_environment_default", + ), + pytest.param( + UpdateFlagRequest({"segment_overrides": []}), id="empty_segment_overrides" + ), + ], +) +def test_update_flag__patch_changing_nothing__writes_nothing( + admin_client_new: APIClient, + changes: UpdateFlagRequest, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given + versions = EnvironmentFeatureVersion.objects.filter( + environment=versioned_environment, feature_id=feature + ) + version_count = versions.count() + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + changes, + format="json", + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "environment_default": { + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + "segment_overrides": [], + } + assert versions.count() == version_count + assert log.events == [] + + def test_get_flag__user_authorised__returns_flag_state( admin_client_new: APIClient, default_feature_value: str, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 7f11bb0b8e2d..8f354ecfdf7f 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -279,7 +279,7 @@ Attributes: ### `features.flag.updated` Logged at `info` from: - - `api/features/future/services.py:315` + - `api/features/future/services.py:319` Attributes: - `environment.id` From b2efd4efafdacad377c49a988361abdb5ff2d61e Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 18:03:57 -0300 Subject: [PATCH 38/39] better error --- api/features/future/exceptions.py | 7 ++++++- api/features/future/views.py | 5 +++-- .../integration/features/future/test_flag_endpoint.py | 5 +++-- .../observability/_events-catalogue.md | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/api/features/future/exceptions.py b/api/features/future/exceptions.py index 9593f226827c..43717e2fcf53 100644 --- a/api/features/future/exceptions.py +++ b/api/features/future/exceptions.py @@ -7,11 +7,16 @@ class ChangeRequestsEnabledError(APIException): """Raised where a flag can only be changed by going through a change request.""" - status_code = status.HTTP_400_BAD_REQUEST + status_code = status.HTTP_409_CONFLICT + default_code = "change_requests_enabled" default_detail = ( "Cannot update flags in an environment with change requests enabled." ) + def __init__(self) -> None: + # DRF's default exception handler renders `detail` alone. + super().__init__({"detail": self.default_detail, "code": self.default_code}) + class DuplicatePriorityError(APIException): """Raised where a flag's segment overrides would end up sharing a priority.""" diff --git a/api/features/future/views.py b/api/features/future/views.py index 612f3d389ec0..569a05b4cf16 100644 --- a/api/features/future/views.py +++ b/api/features/future/views.py @@ -98,15 +98,16 @@ def _update_flag( feature = _get_feature(environment, feature_id) if environment.is_workflow_enabled: + api_error = ChangeRequestsEnabledError() logger.warning( "flag.update_rejected", organisation__id=environment.project.organisation_id, project__id=environment.project_id, environment__id=environment.id, feature__id=feature.id, - reason="change_requests_enabled", + reason=api_error.default_code, ) - raise ChangeRequestsEnabledError() + raise api_error serializer = UpdateFlagSerializer( data=request.data, diff --git a/api/tests/integration/features/future/test_flag_endpoint.py b/api/tests/integration/features/future/test_flag_endpoint.py index 444f7888f64a..bb4cfee59408 100644 --- a/api/tests/integration/features/future/test_flag_endpoint.py +++ b/api/tests/integration/features/future/test_flag_endpoint.py @@ -1459,7 +1459,7 @@ def test_update_flag__put_environment_default_and_segment_overrides__replaces_bo ] -def test_update_flag__change_requests_enabled__responds_400( +def test_update_flag__change_requests_enabled__responds_409( admin_client_new: APIClient, environment_api_key: str, feature: int, @@ -1478,9 +1478,10 @@ def test_update_flag__change_requests_enabled__responds_400( ) # Then - assert response.status_code == 400 + assert response.status_code == 409 assert response.json() == { "detail": "Cannot update flags in an environment with change requests enabled.", + "code": "change_requests_enabled", } environment_default = FeatureState.objects.get_live_feature_states( environment=versioned_environment, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 8f354ecfdf7f..89df6e7eb72f 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -267,7 +267,7 @@ Attributes: ### `features.flag.update_rejected` Logged at `warning` from: - - `api/features/future/views.py:101` + - `api/features/future/views.py:102` Attributes: - `environment.id` From 27dcc94a2536a836e444e6916fa1b79c4796813b Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 14 Aug 2026 18:06:40 -0300 Subject: [PATCH 39/39] this aint the korok forest --- docs/vercel.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/vercel.json b/docs/vercel.json index 0f64bc33a629..891cc1be0a89 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -731,6 +731,10 @@ { "source": "/basic-features", "destination": "/flagsmith-concepts/data-model" + }, + { + "source": "/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags", + "destination": "/managing-flags/updating-flags" } ], "trailingSlash": false