diff --git a/api/api/urls/future.py b/api/api/urls/future.py new file mode 100644 index 000000000000..7fd42673cfe0 --- /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 FlagAPIView + +app_name = "future" + +urlpatterns = [ + path( + "environments//features//", + FlagAPIView.as_view(), + name="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/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 05486661e97e..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, @@ -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: @@ -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')" ) 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/exceptions.py b/api/features/future/exceptions.py new file mode 100644 index 000000000000..43717e2fcf53 --- /dev/null +++ b/api/features/future/exceptions.py @@ -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." diff --git a/api/features/future/mappers.py b/api/features/future/mappers.py new file mode 100644 index 000000000000..fbfa513d86c0 --- /dev/null +++ b/api/features/future/mappers.py @@ -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), + ) diff --git a/api/features/future/permissions.py b/api/features/future/permissions.py new file mode 100644 index 000000000000..6a9d84dc03fd --- /dev/null +++ b/api/features/future/permissions.py @@ -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: + """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..b9a8ee046ba9 --- /dev/null +++ b/api/features/future/serializers.py @@ -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) + + +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 + 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..30bb62eaa6c2 --- /dev/null +++ b/api/features/future/services.py @@ -0,0 +1,348 @@ +"""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 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, + 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") + + +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", environment_default.enabled) + 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() + + +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] + 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, + ) + + _check_priorities(environment, feature, version) + + 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.""" + 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(): + 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/types.py b/api/features/future/types.py new file mode 100644 index 000000000000..4a7702a4effc --- /dev/null +++ b/api/features/future/types.py @@ -0,0 +1,59 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +from collections.abc import Sequence + +from typing_extensions import NotRequired, TypedDict + +from features.feature_states.models import FeatureValueType + + +class FlagValue(TypedDict): + type: FeatureValueType + value: str + + +class Variant(TypedDict): + id: int + 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/features/future/views.py b/api/features/future/views.py new file mode 100644 index 000000000000..569a05b4cf16 --- /dev/null +++ b/api/features/future/views.py @@ -0,0 +1,126 @@ +"""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, ValidationError +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.future.exceptions import ChangeRequestsEnabledError +from features.future.permissions import ( + check_read_permissions, + check_update_permissions, +) +from features.future.serializers import UpdateFlagSerializer +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") + + +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, + 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) + + if not isinstance(request.data, Mapping): + raise ValidationError("Expected an object.") + + environment = _get_environment(environment_key) + check_update_permissions(request.user, environment, request.data) + 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=api_error.default_code, + ) + raise api_error + + 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 1adc9aca6f81..ac6174e4d7e6 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() @@ -218,36 +219,42 @@ 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, + }, + ) + assert response.status_code == status.HTTP_201_CREATED + return int(response.json()["id"]) @pytest.fixture() @@ -542,11 +549,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/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_flag_endpoint.py b/api/tests/integration/features/future/test_flag_endpoint.py new file mode 100644 index 000000000000..bb4cfee59408 --- /dev/null +++ b/api/tests/integration/features/future/test_flag_endpoint.py @@ -0,0 +1,2062 @@ +"""https://docs.flagsmith.com/managing-flags/updating-flags""" + +import pytest +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, +) +from pytest_structlog import StructuredLogCapture +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.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 +from tests.types import WithEnvironmentPermissionsCallable +from users.models import FFAdminUser + + +@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", + ) + assert response.status_code == 201 + return int(response.json()["id"]) + + +@pytest.fixture() +def segment_in_other_project( + admin_client: APIClient, + organisation: int, +) -> int: + project_response = admin_client.post( + "/api/v1/projects/", + {"name": "Other Project", "organisation": organisation}, + format="json", + ) + assert project_response.status_code == 201 + other_project = project_response.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", + ) + assert response.status_code == 201 + return int(response.json()["id"]) + + +@pytest.fixture() +def mv_feature_variants( + admin_client: APIClient, + project: int, + mv_feature: int, +) -> list[int]: + return [ + create_mv_option_with_api( + admin_client, + project, + mv_feature, + default_percentage_allocation, + value, + ) + for value, default_percentage_allocation in [("a", 10), ("b", 20)] + ] + + +@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, + 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, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + 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_new.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 + 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_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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 + 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_new: APIClient, + default_feature_value: str, + 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_new.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", + ) + + # 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": [], + } + 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: 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( + admin_client_new: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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 + 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( + admin_client_new: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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 + ) + 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__new_segment_override_without_state__inherits_environment_default_state( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client_new.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 + log.events.clear() + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"segment_overrides": [{"segment": {"id": segment}}]}), + 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": 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.enabled is True + 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( + admin_client_new: APIClient, + default_feature_value: str, + 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 + 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 + 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": 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} + 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( + admin_client_new: APIClient, + default_feature_value: str, + 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}, "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)] + 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": 5}]} + ), + 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_2}, + "priority": 1, + "enabled": True, + "value": {"type": "string", "value": default_feature_value}, + "variants": [], + }, + { + "segment": {"id": segment}, + "priority": 5, + "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: 5, 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_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, + 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 + + # When + response = admin_client_new.patch( + f"/api/__future__/environments/{environment_api_key}/features/{mv_feature}/", + UpdateFlagRequest( + { + "segment_overrides": [ + { + "segment": {"id": segment}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": 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": [ + {"id": variant_a, "weight": 10}, + {"id": variant_b, "weight": 20}, + ], + }, + "segment_overrides": [ + { + "segment": {"id": segment}, + "priority": 0, + "enabled": False, + "value": {"type": "string", "value": default_feature_value}, + "variants": [ + {"id": variant_a, "weight": 25}, + {"id": 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_id", "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_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__new_segment_override_without_variants__inherits_environment_default_variants( + admin_client_new: 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_new.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_new.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_new: 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_new.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_new.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_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given + setup_response = admin_client_new.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 + log.events.clear() + + # When + response = admin_client_new.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 + 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_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( + { + "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 + log.events.clear() + + # When + response = admin_client_new.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": True, + "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" + ) + 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_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( + { + "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 + log.events.clear() + + # When + response = admin_client_new.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 + ) + 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_409( + admin_client_new: APIClient, + 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.patch( + f"/api/__future__/environments/{environment_api_key}/features/{feature}/", + UpdateFlagRequest({"environment_default": {"enabled": True}}), + format="json", + ) + + # Then + 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, + feature_id=feature, + 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( + admin_client_new: APIClient, + default_feature_value: str, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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 + assert log.events == [] + + +@pytest.mark.parametrize("body", [[], "segment_overrides"]) +def test_update_flag__body_is_not_an_object__responds_400( + admin_client_new: APIClient, + body: object, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given + unknown_feature = feature + 1 + + # When + response = admin_client_new.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."} + assert log.events == [] + + +def test_update_flag__unknown_environment__responds_404( + admin_client_new: APIClient, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client_new.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."} + 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 + 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 + assert log.events == [] + + +def test_update_flag__unknown_segment__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + versioned_environment: Environment, +) -> None: + # Given + unknown_segment = segment + 1 + + # When + response = admin_client_new.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() + ) + assert log.events == [] + + +def test_update_flag__duplicate_segment_overrides__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment: int, + versioned_environment: Environment, +) -> None: + # Given / When + 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}, "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() + ) + assert log.events == [] + + +def test_update_flag__segment_from_another_project__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + segment_in_other_project: int, + versioned_environment: Environment, +) -> None: + # Given / When + response = admin_client_new.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() + ) + assert log.events == [] + + +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, + log: StructuredLogCapture, + 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() + 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( + staff_user: FFAdminUser, + staff_client: APIClient, + environment: int, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + 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 + 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_new: 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 + unknown_variant = variant_b + 1 + + # When + response = admin_client_new.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} + assert log.events == [] + + +def test_update_flag__variant_omitted__responds_400( + admin_client_new: 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_new.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} + assert log.events == [] + + +def test_update_flag__put_environment_default_without_variants__responds_400( + admin_client_new: 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_new.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_new: 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_new.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} + assert log.events == [] + + +def test_update_flag__variants_on_standard_feature__responds_400( + admin_client_new: APIClient, + environment_api_key: str, + feature: int, + log: StructuredLogCapture, + mv_feature_variants: list[int], + versioned_environment: Environment, +) -> None: + # Given + variant_a, _ = mv_feature_variants + + # When + response = admin_client_new.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() + ) + assert log.events == [] 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/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index e527d69b1a52..89df6e7eb72f 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:102` + +Attributes: + - `environment.id` + - `feature.id` + - `organisation.id` + - `project.id` + - `reason` + +### `features.flag.updated` + +Logged at `info` from: + - `api/features/future/services.py:319` + +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` 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..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](/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 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. diff --git a/docs/docs/managing-flags/updating-flags.md b/docs/docs/managing-flags/updating-flags.md new file mode 100644 index 000000000000..c131f45626ca --- /dev/null +++ b/docs/docs/managing-flags/updating-flags.md @@ -0,0 +1,339 @@ +--- +title: 'Experimental: Updating Flags' +sidebar_label: 'Experimental: Updating Flags' +--- + +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. + +:::caution + +**These endpoints are experimental and may change without notice.** Note these limitations: + +- 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. + +::: + +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 +`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: + +| Type | Example | +| --------- | -------------------------------------- | +| `string` | `{"type": "string", "value": "hello"}` | +| `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: + +```bash +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 '{ + "environment_default": {"enabled": true} + }' +``` + +```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: + +```bash +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 '{ + "environment_default": { + "value": {"type": "integer", "value": "1000"} + } + }' +``` + +```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: + +```bash +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 '{ + "environment_default": { + "enabled": false + }, + "segment_overrides": [ + { + "segment": {"id": 101}, + "enabled": true, + "priority": 10 + }, + { + "segment": {"id": 202}, + "enabled": true, + "priority": 20 + } + ] + }' +``` + +```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 +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 '{ + "segment_overrides": [ + { + "segment": {"id": 101}, + "value": {"type": "string", "value": "enterprise"} + } + ] + }' +``` + +```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. 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 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 + +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"} + } + ] + }' +``` + +```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 +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`. + +Re-weight the variants for a feature in the environment: + +```bash +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 '{ + "environment_default": { + "variants": [ + {"id": 33, "weight": 10}, + {"id": 34, "weight": 10.5} + ] + } + }' +``` + +```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 +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 '{ + "segment_overrides": [ + { + "segment": {"id": 101}, + "variants": [ + {"id": 33, "weight": 25}, + {"id": 34, "weight": 25} + ] + } + ] + }' +``` + +```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} + ] + } + ] +} +``` + +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, unless `PATCH` is updating an existing +override, which keeps its own weights. 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 diff --git a/openapi.yaml b/openapi.yaml index 9f0e3f19777a..b65e065590eb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12,6 +12,107 @@ servers: - url: 'https://api.flagsmith.com' 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.' + 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 +25503,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 +27527,12 @@ components: - is_admin - issuer - name + Type68aEnum: + type: string + enum: + - string + - integer + - boolean Type975Enum: description: |- * `int` - Integer @@ -27699,6 +27817,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: