Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
35d86ab
fix(Segments): Refuse system segment override changes through the fla…
khvn26 Oct 8, 2026
9199368
feat(API)!: Remove the experimental flag update endpoints
khvn26 Oct 8, 2026
2221c36
fix(Flags): Enforce the segment override limit on the flag API
khvn26 Oct 8, 2026
dff0996
refactor(Flags): Write live segment overrides through the flag API
khvn26 Oct 9, 2026
b986294
fix(Segments): Refuse system segment changes through the legacy APIs
khvn26 Oct 9, 2026
3344157
test(Segments): Cover the legacy segment override endpoints
khvn26 Oct 9, 2026
11716c9
refactor(Flags): Read live overrides once when reordering them
khvn26 Oct 9, 2026
b84e23a
fix(Segments): Refuse change requests to system segments when created
khvn26 Oct 9, 2026
e27a57d
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
248038c
fix(Segments): Keep drafting feature segments of v2 environments
khvn26 Oct 9, 2026
f29ca18
test(Segments): Cover moving a legacy override to another segment
khvn26 Oct 9, 2026
4c577ee
refactor(Flags): Address review
khvn26 Oct 9, 2026
bdfd227
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
b82450d
refactor(Flags): Address review
khvn26 Oct 9, 2026
05c344c
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
869e688
refactor(Flags): Address review
khvn26 Oct 9, 2026
53624ad
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
d7cef1f
refactor(Flags): Refuse any change to system segment overrides in the…
khvn26 Oct 9, 2026
cc1e2ff
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
9b1d68a
refactor(Flags): Keep legacy support out of the flag API
khvn26 Oct 9, 2026
a0706cc
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
644d9ef
refactor(Flags): Write override values as before
khvn26 Oct 9, 2026
3d43af5
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
42797b2
refactor(Flags): Extract running delete hooks of deleted querysets
khvn26 Oct 9, 2026
49fd4a4
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
45b0f98
refactor(Flags): Narrow the draft feature state being updated without…
khvn26 Oct 9, 2026
cf0f0da
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
79168dc
fix(Flags): Refuse variant weights exceeding 100% in the flag API ser…
khvn26 Oct 12, 2026
38ae1a6
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 12, 2026
ae3a55d
refactor(Flags): Write flags through the flag API with AuthorData
khvn26 Oct 12, 2026
dd4e49e
refactor(Experiments): Write rollouts through the flag API
khvn26 Oct 12, 2026
f5ce5e2
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 12, 2026
556afe7
refactor(Core): Type the users AuthorData.from_user takes
khvn26 Oct 12, 2026
88c697f
refactor(Experiments): Specify rollout variants as the flag API takes…
khvn26 Oct 12, 2026
85072ec
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 12, 2026
4147d06
style: Tidy imports
khvn26 Oct 12, 2026
f25b8ef
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 0 additions & 34 deletions api/api/urls/experiments.py

This file was deleted.

4 changes: 0 additions & 4 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@
),
re_path(r"^api/v1/", include("api.urls.v1", namespace="api-v1")),
re_path(r"^api/v2/", include("api.urls.v2", namespace="api-v2")),
re_path(
r"^api/experiments/",
include("api.urls.experiments", namespace="api-experiments"),
),
re_path(
r"^api/__future__/", include("api.urls.future", namespace="api-future")
),
Expand Down
10 changes: 10 additions & 0 deletions api/core/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework.request import Request

from api_keys.models import MasterAPIKey
from api_keys.user import APIKeyUser
from users.models import FFAdminUser


Expand All @@ -23,3 +24,12 @@ def from_request(cls, request: "Request") -> "AuthorData":
return cls(api_key=request.user.key)
else:
raise ValueError("Request user must be FFAdminUser or have an API key")

@classmethod
def from_user(cls, user: "FFAdminUser | APIKeyUser") -> "AuthorData":
from users.models import FFAdminUser

# `UserABC.__subclasshook__` matches any user against `APIKeyUser`.
if isinstance(user, FFAdminUser):
return cls(user=user)
return cls(api_key=user.key)
13 changes: 13 additions & 0 deletions api/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
from rest_framework.exceptions import APIException


class APIError(APIException):
"""Refuses a request with an `APIErrorDetail` body, telling clients what went
wrong by code."""

def __init__(self, message: str | None = None) -> None:
super().__init__(
{
"code": str(self.default_code),
"message": message or str(self.default_detail),
}
)


class ChangeRequestsEnabledError(APIException):
"""Raised where a change can only be made by going through a change request."""

Expand Down
4 changes: 2 additions & 2 deletions api/experimentation/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from experimentation.stats import Inference, VariantStats
from experimentation.types import ExposureGranularity
from features.feature_states.models import FeatureValueType
from features.versioning.dataclasses import MultivariateValueChangeSet
from features.future.types import Variant


@dataclass(frozen=True)
Expand All @@ -20,7 +20,7 @@ class RolloutSpec:
rollout_percentage: float
feature_state_value: str
value_type: FeatureValueType
multivariate_values: list[MultivariateValueChangeSet]
variants: list[Variant]
author: AuthorData
audience: AudienceSpec | None = None

Expand Down
10 changes: 5 additions & 5 deletions api/experimentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
MultivariateValueSerializer,
)
from features.feature_types import MULTIVARIATE
from features.future.types import Variant
from features.models import Feature
from features.multivariate.serializers import NestedMultivariateFeatureOptionSerializer
from features.versioning.dataclasses import MultivariateValueChangeSet


class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
Expand Down Expand Up @@ -264,10 +264,10 @@ def to_spec(data: dict[str, Any], request: Any) -> RolloutSpec:
rollout_percentage=data["rollout_percentage"],
feature_state_value=value["value"],
value_type=value["type"],
multivariate_values=[
MultivariateValueChangeSet(
multivariate_feature_option_id=mv["multivariate_feature_option"],
percentage_allocation=mv["percentage_allocation"],
variants=[
Variant(
id=mv["multivariate_feature_option"],
weight=mv["percentage_allocation"],
)
for mv in data.get("multivariate_feature_state_values", [])
],
Expand Down
140 changes: 69 additions & 71 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,13 @@
from experimentation.warehouses import flagsmith
from experimentation.warehouses.exceptions import UnsupportedWarehouseOperation
from experimentation.warehouses.registry import get_warehouse
from features.feature_segments.services import get_next_segment_override_priority
from features.feature_states.models import API_VALUE_TYPES
from features.future.services import update_flag, write_variants
from features.future.types import SegmentOverrideRequest
from features.models import FeatureState
from features.value_types import BOOLEAN, STRING
from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet
from features.versioning.versioning_service import (
get_environment_flags_list,
update_flag,
update_multivariate_values,
)
from features.versioning.versioning_service import get_environment_flags_list
from integrations.flagsmith.client import get_openfeature_client
from segments.models import Condition, Segment, SegmentManagedBy, SegmentRule
from segments.services import write_segment_rules
Expand Down Expand Up @@ -777,7 +775,7 @@ def _validate_audience_change(experiment: Experiment, audience: AudienceSpec) ->


def validate_rollout_spec(experiment: Experiment, spec: RolloutSpec) -> None:
option_ids = [v.multivariate_feature_option_id for v in spec.multivariate_values]
option_ids = [variant["id"] for variant in spec.variants]
if len(option_ids) != len(set(option_ids)):
raise ValidationError("Multivariate options must be unique")
valid_option_ids = set(
Expand All @@ -787,7 +785,7 @@ def validate_rollout_spec(experiment: Experiment, spec: RolloutSpec) -> None:
raise ValidationError(
f"Multivariate options {sorted(invalid)} do not belong to the feature"
)
total = sum(v.percentage_allocation for v in spec.multivariate_values)
total = sum(variant["weight"] for variant in spec.variants)
if total > 100:
raise ValidationError(
f"Multivariate allocations must not exceed 100%, got {total}%."
Expand Down Expand Up @@ -826,10 +824,7 @@ def _is_noop_rollout(
return {
mv.multivariate_feature_option_id: mv.percentage_allocation
for mv in override.multivariate_feature_state_values.all()
} == {
mv.multivariate_feature_option_id: mv.percentage_allocation
for mv in spec.multivariate_values
}
} == {variant["id"]: variant["weight"] for variant in spec.variants}


def _create_rollout_segment(
Expand Down Expand Up @@ -880,18 +875,20 @@ def _get_live_rollout_override(experiment: Experiment) -> FeatureState | None:


def _update_live_feature_state(
feature_state: FeatureState, change_set: FlagChangeSet
feature_state: FeatureState, override: SegmentOverrideRequest
) -> None:
feature_state.enabled = change_set.enabled
feature_state.enabled = override["enabled"]
feature_state.save()
feature_state.feature_state_value.set_value(
change_set.feature_state_value, change_set.type_
)
feature_state.feature_state_value.save()
update_multivariate_values(feature_state, change_set.multivariate_values)
if value := override.get("value"):
feature_state.feature_state_value.set_value(value["value"], value["type"])
feature_state.feature_state_value.save()
if (variants := override.get("variants")) is not None:
write_variants(feature_state, variants)


def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet) -> None:
def _update_rollout_in_place(
experiment: Experiment, override: SegmentOverrideRequest, author: AuthorData
) -> None:
"""Write the rollout-segment override, keeping variant assignment stable.

Under v2 versioning, ``update_flag`` clones the override into a fresh feature
Expand All @@ -905,12 +902,25 @@ def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet)
This is a temporary solution until we find a permanent fix for the
underlying salting issue: https://github.com/Flagsmith/flagsmith/issues/7913
"""
if experiment.environment.use_v2_feature_versioning and (
override := _get_live_rollout_override(experiment)
):
_update_live_feature_state(override, change_set)
live_override = _get_live_rollout_override(experiment)
if experiment.environment.use_v2_feature_versioning and live_override:
_update_live_feature_state(live_override, override)
return
update_flag(experiment.environment, experiment.feature, change_set)
if not live_override:
override = {
**override,
"priority": get_next_segment_override_priority(
environment=experiment.environment, feature=experiment.feature
),
}
update_flag(
environment=experiment.environment,
feature=experiment.feature,
changes={"segment_overrides": [override]},
replace=False,
author=author,
system=True,
)


def _reset_default_allocations_to_control(
Expand All @@ -922,35 +932,22 @@ def _reset_default_allocations_to_control(
Run once, when the rollout segment is first created: identities outside the
rollout cohort should all receive control while the experiment runs.
"""
(default_state,) = get_environment_flags_list(
environment=experiment.environment,
additional_filters=Q(
feature_id=experiment.feature_id,
feature_segment__isnull=True,
identity__isnull=True,
),
)
str_value, value_type = _serialize_feature_state_value(
default_state.feature_state_value
)
update_flag(
experiment.environment,
experiment.feature,
FlagChangeSet(
author=author,
enabled=default_state.enabled,
feature_state_value=str_value,
type_=value_type,
multivariate_values=[
MultivariateValueChangeSet(
multivariate_feature_option_id=option_id,
percentage_allocation=0,
)
for option_id in experiment.feature.multivariate_options.values_list(
"id", flat=True
)
],
),
environment=experiment.environment,
feature=experiment.feature,
changes={
"environment_default": {
"variants": [
{"id": option_id, "weight": 0}
for option_id in experiment.feature.multivariate_options.values_list(
"id", flat=True
)
]
}
},
replace=False,
author=author,
system=True,
)


Expand Down Expand Up @@ -985,14 +982,13 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
_reset_default_allocations_to_control(experiment, spec.author)
_update_rollout_in_place(
experiment,
FlagChangeSet(
author=spec.author,
enabled=spec.enabled,
feature_state_value=spec.feature_state_value,
type_=spec.value_type,
segment_id=segment.id,
multivariate_values=spec.multivariate_values,
),
{
"segment": {"id": segment.id},
"enabled": spec.enabled,
"value": {"type": spec.value_type, "value": spec.feature_state_value},
"variants": spec.variants,
},
spec.author,
)
# Segment condition changes don't trigger a rebuild on their own.
transaction.on_commit(
Expand Down Expand Up @@ -1032,7 +1028,7 @@ def _serialize_feature_state_value(
value: FeatureStateValue,
) -> tuple[str, FeatureValueType]:
"""Render a stored feature state value as the (string, API type) pair that
a `FlagChangeSet` expects."""
the flag API expects."""
if value.value is None:
return "", "string"
return (
Expand Down Expand Up @@ -1100,19 +1096,21 @@ def _get_experiment_audience(experiment: Experiment) -> dict[str, typing.Any]:

def enable_experiment_rollout(experiment: Experiment, author: AuthorData) -> None:
rollout = get_experiment_rollout(experiment)
if rollout is None or rollout["enabled"]:
if (
rollout is None
or rollout["enabled"]
or (rollout_segment_id := experiment.rollout_segment_id) is None
):
return

value = rollout["feature_state_value"]
_update_rollout_in_place(
experiment,
FlagChangeSet(
author=author,
enabled=True,
feature_state_value=value["value"],
type_=value["type"],
segment_id=experiment.rollout_segment_id,
),
{
"segment": {"id": rollout_segment_id},
"enabled": True,
"value": rollout["feature_state_value"],
},
author,
)


Expand Down
Loading
Loading