Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1a6d89a
fix(Segments): Refuse system segment override changes through the fla…
khvn26 Oct 8, 2026
c5d13c9
feat(API)!: Remove the experimental flag update endpoints
khvn26 Oct 8, 2026
3f70752
fix(Flags): Enforce the segment override limit on the flag API
khvn26 Oct 8, 2026
1a13e13
refactor(Flags): Write live segment overrides through the flag API
khvn26 Oct 9, 2026
9ac22e6
fix(Segments): Refuse system segment changes through the legacy APIs
khvn26 Oct 9, 2026
e044b9b
test(Segments): Cover the legacy segment override endpoints
khvn26 Oct 9, 2026
7612e24
refactor(Flags): Read live overrides once when reordering them
khvn26 Oct 9, 2026
ae291bf
fix(Segments): Refuse change requests to system segments when created
khvn26 Oct 9, 2026
2638029
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
c221da3
fix(Segments): Keep drafting feature segments of v2 environments
khvn26 Oct 9, 2026
9fcf1e9
test(Segments): Cover moving a legacy override to another segment
khvn26 Oct 9, 2026
862dddc
refactor(Flags): Address review
khvn26 Oct 9, 2026
5874270
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
3cfd3ee
refactor(Flags): Address review
khvn26 Oct 9, 2026
95c8b79
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
29a59a5
refactor(Flags): Address review
khvn26 Oct 9, 2026
52976b0
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
333cd2d
refactor(Flags): Refuse any change to system segment overrides in the…
khvn26 Oct 9, 2026
599db7b
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
937c369
refactor(Flags): Keep legacy support out of the flag API
khvn26 Oct 9, 2026
97eedcc
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
966fb27
refactor(Flags): Write override values as before
khvn26 Oct 9, 2026
9641ab5
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
a3d017c
refactor(Flags): Extract running delete hooks of deleted querysets
khvn26 Oct 9, 2026
2744b76
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 2026
e27ff85
refactor(Flags): Narrow the draft feature state being updated without…
khvn26 Oct 9, 2026
5477bec
chore: Update documentation artefacts
flagsmith-engineering[bot] Oct 9, 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
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
24 changes: 8 additions & 16 deletions api/features/dependencies/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rest_framework import status
from rest_framework.exceptions import APIException, NotFound, PermissionDenied

from core.exceptions import APIError
from core.types import APIErrorDetail
from features.dependencies.types import (
DependencyPath,
Expand Down Expand Up @@ -105,7 +106,7 @@ def get_message(self, path: DependencyPath) -> str:
)


class DependencyNotFoundError(NotFound):
class DependencyNotFoundError(APIError, NotFound):
"""Raised where the feature does not depend on the prerequisite in the environment."""

default_code = "dependency_not_found"
Expand All @@ -114,38 +115,29 @@ def __init__(
self, feature_name: FeatureName, prerequisite_feature_name: FeatureName
) -> None:
super().__init__(
{
"code": self.default_code,
"message": f'The feature "{feature_name}" does not depend'
f' on the feature "{prerequisite_feature_name}".',
}
f'The feature "{feature_name}" does not depend'
f' on the feature "{prerequisite_feature_name}".'
)


class FeatureDependencyPermissionDeniedError(PermissionDenied):
class FeatureDependencyPermissionDeniedError(APIError, PermissionDenied):
"""Raised where a caller may not manage the environment's segment overrides."""

def __init__(self) -> None:
permission_name = MANAGE_SEGMENT_OVERRIDES.capitalize().replace("_", " ")
super().__init__(
{
"code": self.default_code,
"message": f'The permission "{permission_name}" is necessary'
" to manage feature dependencies.",
}
f'The permission "{permission_name}" is necessary'
" to manage feature dependencies."
)


class PrerequisiteIsSelfError(APIException):
class PrerequisiteIsSelfError(APIError):
"""Raised where a feature is requested as its own prerequisite."""

status_code = status.HTTP_400_BAD_REQUEST
default_code = "prerequisite_is_self"
default_detail = "A feature cannot depend on itself."

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


class PrerequisiteFeatureNotFoundError(APIException):
"""Raised where a segment condition names a feature that does not exist."""
Expand Down
2 changes: 2 additions & 0 deletions api/features/dependencies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def create_flag_dependency(
},
replace=False,
author=author,
system=True,
)
overrides.get(segment=segment).to(0)
_create_dependency_audit_log(
Expand Down Expand Up @@ -486,6 +487,7 @@ def delete_flag_dependency(
feature=feature,
segment_id=edge["segment"]["id"],
author=author,
system=True,
)
_create_dependency_audit_log(
environment=environment,
Expand Down
11 changes: 4 additions & 7 deletions api/features/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from rest_framework import status
from rest_framework.exceptions import APIException, NotFound

from core.exceptions import APIError


class FeatureStateVersionError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
Expand All @@ -15,15 +17,10 @@ def __init__(self, version: int):
)


class FeatureNotFoundError(NotFound):
class FeatureNotFoundError(APIError, NotFound):
"""Raised where a feature ID is not in the environment's project."""

default_code = "feature_not_found"

def __init__(self, feature_id: int) -> None:
super().__init__(
{
"code": self.default_code,
"message": f"Feature ID '{feature_id}' does not exist in the project.",
}
)
super().__init__(f"Feature ID '{feature_id}' does not exist in the project.")
4 changes: 2 additions & 2 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def notify_github_on_link(self): # type: ignore[no-untyped-def]
)

call_github_task(
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
organisation_id=self.feature.project.organisation_id,
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
feature=self.feature,
segment_name=None,
Expand All @@ -154,7 +154,7 @@ def notify_github_on_unlink(self) -> None:
.github_config.first()
):
call_github_task(
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
organisation_id=self.feature.project.organisation_id,
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
feature=self.feature,
segment_name=None,
Expand Down
65 changes: 65 additions & 0 deletions api/features/feature_segments/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from features.future.mappers import map_flag_value
from features.future.types import (
FlagValue,
SegmentOverrideRequest,
SegmentReference,
Variant,
)
from features.models import FeatureStateValue
from features.types import LegacyFeatureStateData


def map_legacy_flag_value(
feature_state_data: LegacyFeatureStateData,
) -> FlagValue | None:
"""Map the value of feature state data validated by a legacy API, if any.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
if value_data := feature_state_data.get("feature_state_value"):
return map_flag_value(FeatureStateValue(**value_data))
return None


def clears_legacy_flag_value(feature_state_data: LegacyFeatureStateData) -> bool:
"""Whether feature state data validated by a legacy API clears the value.

The flag API can't clear values, but the dashboard sends empty ones as null.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
return bool(feature_state_data.get("feature_state_value")) and (
map_legacy_flag_value(feature_state_data) is None
)


def map_feature_state_data_to_segment_override(
segment_id: int,
feature_state_data: LegacyFeatureStateData,
*,
priority: int | None = None,
) -> SegmentOverrideRequest:
"""Map the feature state data validated by a legacy endpoint to an override.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
override = SegmentOverrideRequest(segment=SegmentReference(id=segment_id))
if priority is not None:
override["priority"] = priority
if "enabled" in feature_state_data:
override["enabled"] = feature_state_data["enabled"]
if value := map_legacy_flag_value(feature_state_data):
override["value"] = value
if (
multivariate_values := feature_state_data.get(
"multivariate_feature_state_values"
)
) is not None:
override["variants"] = [
Variant(
id=value["multivariate_feature_option"].id,
weight=value["percentage_allocation"],
)
for value in multivariate_values
]
return override
136 changes: 136 additions & 0 deletions api/features/feature_segments/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
from django.db.models import Max, Q
from django.utils import timezone
from rest_framework.request import Request

from audit.tasks import create_segment_priorities_changed_audit_log
from environments.models import Environment
from features.models import Feature, FeatureSegment, FeatureState
from features.versioning.versioning_service import (
get_current_live_environment_feature_version,
get_environment_flags_list,
)


def get_reordered_priorities(
feature_segments: list[FeatureSegment],
new_priorities: dict[int, int],
) -> dict[int, int]:
"""Get the priorities `FeatureSegment.update_priorities` would leave the
feature segments with, by feature segment ID.

Moving a feature segment to a priority moves the ones in between out of
its way, as the requested moves are made in the order of their priorities.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
priorities = {
feature_segment.id: feature_segment.priority
for feature_segment in feature_segments
}
# Each move reads its feature segment's priority as it was at first.
initial_priorities = priorities.copy()
for feature_segment_id in sorted(
new_priorities, key=lambda id_: (initial_priorities[id_], id_)
):
old_priority = initial_priorities[feature_segment_id]
new_priority = new_priorities[feature_segment_id]
if old_priority == new_priority:
continue
for other_id, priority in priorities.items():
if new_priority <= priority < old_priority:
priorities[other_id] = priority + 1
elif old_priority < priority <= new_priority:
priorities[other_id] = priority - 1
priorities[feature_segment_id] = new_priority
return priorities


def create_priorities_changed_audit_log(
request: Request,
previous_priorities: list[tuple[int, int]],
feature_segment_ids: list[int],
) -> None:
"""Audit a reordering of segment overrides, as `update_priorities` does.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
create_segment_priorities_changed_audit_log.delay(
kwargs={
"previous_id_priority_pairs": previous_priorities,
"feature_segment_ids": feature_segment_ids,
"user_id": getattr(request.user, "id", None),
"master_api_key_id": (
request.master_api_key.id
if hasattr(request, "master_api_key")
else None
),
"changed_at": timezone.now().isoformat(),
}
)


def get_segment_overrides(
*, environment: Environment, feature: Feature
) -> dict[int, FeatureState]:
"""Get the flag's live overrides, by segment ID.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
return {
feature_state.feature_segment.segment_id: feature_state
for feature_state in get_environment_flags_list(
environment,
additional_filters=Q(
feature_id=feature.id,
feature_segment__isnull=False,
identity__isnull=True,
),
additional_prefetch_related_args=["multivariate_feature_state_values"],
)
if feature_state.feature_segment
}


def get_segment_override(
*, environment: Environment, feature: Feature, segment_id: int
) -> FeatureState | None:
"""Get the flag's live override for a segment, if any.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
return get_segment_overrides(environment=environment, feature=feature).get(
segment_id
)


def is_live_segment_override(feature_segment: FeatureSegment) -> bool:
"""Whether a feature segment is the one the flag serves its segment from.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
override = get_segment_override(
environment=feature_segment.environment,
feature=feature_segment.feature,
segment_id=feature_segment.segment_id,
)
return override is not None and override.feature_segment_id == feature_segment.id


def get_next_segment_override_priority(
*, environment: Environment, feature: Feature
) -> int:
"""Get the priority of an override added after the flag's other overrides.

TODO: Remove after https://github.com/Flagsmith/flagsmith/issues/7641
"""
version = (
get_current_live_environment_feature_version(environment.id, feature.id)
if environment.use_v2_feature_versioning
else None
)
highest_priority: int | None = FeatureSegment.objects.filter(
environment=environment,
feature=feature,
environment_feature_version=version,
).aggregate(highest_priority=Max("priority"))["highest_priority"]
return 0 if highest_priority is None else highest_priority + 1
Loading
Loading