Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5c2b471
first the dream
emyller Jul 24, 2026
47f53c9
then the contract
emyller Jul 25, 2026
98593e0
get priorities straight
emyller Aug 3, 2026
e111394
tried so hard, got so far
emyller Aug 4, 2026
51740b8
percentage rules
emyller Aug 5, 2026
4b2328a
dang it Venn
emyller Aug 5, 2026
cfe48fa
未来を見て!
emyller Aug 5, 2026
81077d9
now i see the full picture
emyller Aug 5, 2026
b964909
🚫🐬
emyller Aug 7, 2026
c1ebb0e
clarity
emyller Aug 7, 2026
4889df1
who needs tests
emyller Aug 13, 2026
2278900
ah the happy path
emyller Aug 14, 2026
3209f05
be conservative
emyller Aug 14, 2026
9fbed00
responses!
emyller Aug 14, 2026
0399237
deny
emyller Aug 14, 2026
2d87c6f
it may vary
emyller Aug 14, 2026
96b4b7d
expect telemetry
emyller Aug 14, 2026
1a136cb
Merge remote-tracking branch 'github/main' into feat/update-flag-opti…
emyller Aug 14, 2026
f4bddec
IT'S GREEN
emyller Aug 14, 2026
eb1a497
even more clarity
emyller Aug 14, 2026
14c3324
the laws of relativity don't work here
emyller Aug 14, 2026
348f5a9
chore: Update documentation artefacts
flagsmith-engineering[bot] Aug 14, 2026
f74048f
fix docs
emyller Aug 14, 2026
e0a0f0a
fail safely
emyller Aug 14, 2026
89b7b85
almost ignorable edge case
emyller Aug 14, 2026
ee2c22f
yes it works
emyller Aug 14, 2026
e49f757
DRY
emyller Aug 14, 2026
9ebb0c3
works for both auth types
emyller Aug 14, 2026
dfe720d
py3.11 is still here
emyller Aug 14, 2026
36b9208
fix docs link
emyller Aug 14, 2026
ce77df3
fix contradiction
emyller Aug 14, 2026
2f070b6
get priorities straight (again)
emyller Aug 14, 2026
7da1e6b
fix flag state inheritance
emyller Aug 14, 2026
dfc9da6
chore: Update documentation artefacts
flagsmith-engineering[bot] Aug 14, 2026
1304243
why not a get
emyller Aug 14, 2026
6531905
better name now
emyller Aug 14, 2026
05b52cf
chore: Update documentation artefacts
flagsmith-engineering[bot] Aug 14, 2026
9d640b5
no --allow-empty here
emyller Aug 14, 2026
b2efd4e
better error
emyller Aug 14, 2026
27dcc94
this aint the korok forest
emyller Aug 14, 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
20 changes: 20 additions & 0 deletions api/api/urls/future.py
Original file line number Diff line number Diff line change
@@ -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 FlagAPIView

app_name = "future"

urlpatterns = [
path(
"environments/<str:environment_key>/features/<int:feature_id>/",
FlagAPIView.as_view(),
name="flag",
),
]
3 changes: 3 additions & 0 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
11 changes: 3 additions & 8 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"),
)


Expand Down
4 changes: 4 additions & 0 deletions api/features/feature_states/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class FeatureValueError(ValueError):
"""Raised when a feature value is invalid."""

pass
13 changes: 10 additions & 3 deletions api/features/feature_states/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,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:
Expand Down Expand Up @@ -54,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')"
)

Expand Down
Empty file.
25 changes: 25 additions & 0 deletions api/features/future/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""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_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."""

status_code = status.HTTP_400_BAD_REQUEST
default_detail = "Segment overrides must not share a priority."
63 changes: 63 additions & 0 deletions api/features/future/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""https://docs.flagsmith.com/managing-flags/updating-flags"""

from features.feature_states.models import API_VALUE_TYPES
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:
"""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),
)
50 changes: 50 additions & 0 deletions api/features/future/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""https://docs.flagsmith.com/managing-flags/updating-flags"""

from collections.abc import Mapping

from common.environments.permissions import (
MANAGE_SEGMENT_OVERRIDES,
UPDATE_FEATURE_STATE,
VIEW_ENVIRONMENT,
)
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,
}

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]
) -> None:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""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()
116 changes: 116 additions & 0 deletions api/features/future/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""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,
FlagValue,
SegmentOverrideRequest,
UpdateFlagRequest,
Variant,
)
from features.models import Feature, FeatureStateValue
from segments.models import Segment


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=get_args(FeatureValueType))
value = serializers.CharField(allow_blank=True)
Comment thread
khvn26 marked this conversation as resolved.


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 FeatureValueError as error:
raise serializers.ValidationError(str(error)) from error
Comment thread
emyller marked this conversation as resolved.
Dismissed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Comment thread
khvn26 marked this conversation as resolved.


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
Loading
Loading