Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/environments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
EdgeIdentityWithIdentifierFeatureStateView,
get_edge_identity_overrides,
)
from features.feature_states.views import update_flag
from features.views import (
EnvironmentFeatureStateViewSet,
IdentityFeatureStateViewSet,
Expand Down Expand Up @@ -167,4 +168,9 @@
get_edge_identity_overrides,
name="edge-identity-overrides",
),
path(
"<int:environment_id>/features/<str:feature_name>/update-flag/",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, as discussed with @gagantrivedi, let's remove the feature name from the URL since there is nothing stopping it containing HTTP reserved characters. The endpoint should therefore be:

POST /environments/:key/update-flag

{
  "feature_name": "foo",
  ...
}

update_flag,
name="update-flag",
),
]
22 changes: 22 additions & 0 deletions api/features/feature_states/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response

from environments.models import Environment
from features.models import Feature
from features.serializers import UpdateFlagSerializer


@api_view(http_method_names=["POST"])
def update_flag(request: Request, environment_id: int, feature_name: str) -> Response:
environment = Environment.objects.get(id=environment_id)
feature = Feature.objects.get(name=feature_name, project_id=environment.project_id)

serializer = UpdateFlagSerializer(
data=request.data, context={"request": request, "view": update_flag}
)
serializer.is_valid(raise_exception=True)
serializer.save(environment=environment, feature=feature)

return Response(serializer.data, status=status.HTTP_200_OK)
47 changes: 47 additions & 0 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import typing
from datetime import datetime
from typing import Any

Expand All @@ -18,6 +19,7 @@

from app_analytics.serializers import LabelsQuerySerializerMixin, LabelsSerializer
from environments.identities.models import Identity
from environments.models import Environment
from environments.sdk.serializers_mixins import (
HideSensitiveFieldsSerializerMixin,
)
Expand All @@ -28,6 +30,7 @@
FeatureFlagCodeReferencesRepositoryCountSerializer,
)
from projects.models import Project
from users.models import FFAdminUser
from users.serializers import (
UserIdsSerializer,
UserListSerializer,
Expand All @@ -47,6 +50,8 @@
)
from .models import Feature, FeatureState
from .multivariate.serializers import NestedMultivariateFeatureOptionSerializer
from .versioning.dataclasses import FlagChangeSet
from .versioning.versioning_service import update_flag


class FeatureStateSerializerSmall(serializers.ModelSerializer): # type: ignore[type-arg]
Expand Down Expand Up @@ -671,3 +676,45 @@ def create(self, validated_data: dict) -> FeatureState: # type: ignore[type-arg
{"environment": SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE}
)
return super().create(validated_data) # type: ignore[no-any-return,no-untyped-call]


class UpdateFlagSerializer(serializers.Serializer): # type: ignore[type-arg]
enabled = serializers.BooleanField(required=False)

# TODO: this is introducing _another_ way of handling typing, but it feels closer
# to what we should have done from the start. This might be out of scope for this
# work though.
feature_state_value = serializers.CharField(required=False)
type = serializers.ChoiceField(
choices=["int", "string", "bool", "float"],
required=False,
default="string",
)

segment_id = serializers.IntegerField(required=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to provide priority here as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed we @gagantrivedi, we plan to focus on the environment default case for now, and ignore for segments for now. The logic behind this is it creates a much more streamlined endpoint here for users that don't care about segments.

We could then either:

  1. Update this endpoint to include segment information if we get feedback that it should
  2. Create another similar endpoint which handles this for a specific segment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On further discussions, we have gone back to accommodating segment overrides in this endpoint.

We should make priority optional (if the priority is missing, it just gets added to the end of the list). We see that there are 2 main use cases here:

  1. Send "priority": 1 to add it to the top of the list
  2. Send "priority": null (or don't send) and the segment is added to the end of the list.

The endpoint should handle creating or updating a segment override, and should return in the response payload whether the change was an update or not (the status code should still be 200).


# TODO: multivariate?

@property
def flag_change_set(self) -> FlagChangeSet:
validated_data = self.validated_data
change_set = FlagChangeSet(
enabled=validated_data.get("enabled"),
feature_state_value=validated_data.get("feature_state_value"),
type_=validated_data.get("type"),
segment_id=validated_data.get("segment_id"),
)

request = self.context["request"]
if type(request.user) is FFAdminUser:
change_set.user = request.user
else:
change_set.api_key = request.user.key

return change_set

def save(self, **kwargs: typing.Any) -> FeatureState:
environment: Environment = kwargs["environment"]
feature: Feature = kwargs["feature"]

return update_flag(environment, feature, self.flag_change_set)
18 changes: 18 additions & 0 deletions api/features/versioning/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import typing
from dataclasses import dataclass
from datetime import datetime

from pydantic import BaseModel, computed_field

if typing.TYPE_CHECKING:
from api_keys.models import MasterAPIKey
from users.models import FFAdminUser


class Conflict(BaseModel):
segment_id: int | None = None
Expand All @@ -12,3 +18,15 @@ class Conflict(BaseModel):
@property
def is_environment_default(self) -> bool:
return self.segment_id is None


@dataclass
class FlagChangeSet:
enabled: bool
feature_state_value: str
type_: str

user: typing.Optional["FFAdminUser"] = None
api_key: typing.Optional["MasterAPIKey"] = None

segment_id: str | None = None
78 changes: 77 additions & 1 deletion api/features/versioning/versioning_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from django.db.models import Prefetch, Q, QuerySet
from django.utils import timezone

from core.constants import BOOLEAN, FLOAT, INTEGER, STRING
from environments.models import Environment
from features.models import FeatureState
from features.models import Feature, FeatureState, FeatureStateValue
from features.versioning.dataclasses import FlagChangeSet
from features.versioning.models import EnvironmentFeatureVersion


Expand Down Expand Up @@ -101,6 +103,80 @@ def get_current_live_environment_feature_version(
)


def update_flag(
environment: Environment, feature: Feature, change_set: FlagChangeSet
) -> FeatureState:
if environment.use_v2_feature_versioning:
return _update_flag_for_versioning_v2(environment, feature, change_set)
else:
return _update_flag_for_versioning_v1(environment, feature, change_set)


def _update_flag_for_versioning_v2(
environment: Environment, feature: Feature, change_set: FlagChangeSet
) -> FeatureState:
new_version = EnvironmentFeatureVersion.objects.create(
environment=environment,
feature=feature,
created_by=change_set.user,
created_by_api_key=change_set.api_key,
)

target_feature_state: FeatureState = new_version.feature_states.get(
feature_segment__segment_id=change_set.segment_id,
)

target_feature_state.enabled = change_set.enabled
target_feature_state.save()

_update_feature_state_value(target_feature_state.feature_state_value, change_set)

new_version.publish(
published_by=change_set.user, published_by_api_key=change_set.api_key
)

return target_feature_state


def _update_flag_for_versioning_v1(
environment: Environment, feature: Feature, change_set: FlagChangeSet
) -> FeatureState:
latest_feature_states = get_environment_flags_dict(
environment=environment,
feature_name=feature.name,
additional_filters=Q(feature_segment__segment_id=change_set.segment_id),
)
assert len(latest_feature_states) == 1

target_feature_state = list(latest_feature_states.values())[0]
target_feature_state.enabled = change_set.enabled
target_feature_state.save()

_update_feature_state_value(target_feature_state.feature_state_value, change_set)

return target_feature_state


def _update_feature_state_value(
fsv: FeatureStateValue, change_set: FlagChangeSet
) -> None:
match change_set.type_:
case "string":
fsv.string_value = change_set.feature_state_value
fsv.type = STRING
case "int":
fsv.integer_value = int(change_set.feature_state_value)
fsv.type = INTEGER
case "bool":
fsv.boolean_value = change_set.feature_state_value in ("True", "true", "1")
fsv.type = BOOLEAN
case "float":
fsv.float_value = float(change_set.feature_state_value)
fsv.type = FLOAT

fsv.save()


def _get_feature_states_queryset(
environment: "Environment",
feature_name: str | None = None,
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import json

import pytest
from common.environments.permissions import UPDATE_FEATURE_STATE
from django.urls import reverse
from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped]
from rest_framework import status
from rest_framework.test import APIClient

from environments.models import Environment
from features.models import Feature
from features.versioning.versioning_service import (
get_environment_flags_list,
)
from tests.types import WithEnvironmentPermissionsCallable


@pytest.mark.parametrize(
"environment_",
(lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")),
)
def test_update_flag(
staff_client: APIClient,
feature: Feature,
environment_: Environment,
with_environment_permissions: WithEnvironmentPermissionsCallable,
) -> None:
# Given
with_environment_permissions([UPDATE_FEATURE_STATE]) # type: ignore[call-arg]
url = reverse(
"api-v1:environments:update-flag",
kwargs={"environment_id": environment_.id, "feature_name": feature.name},
)

data = {"enabled": True, "feature_state_value": "42", "type": "int"}

# When
response = staff_client.post(
url, data=json.dumps(data), content_type="application/json"
)

# Then
assert response.status_code == status.HTTP_200_OK

latest_flags = get_environment_flags_list(
environment=environment_, feature_name=feature.name
)

assert latest_flags[0].enabled is True
assert latest_flags[0].get_feature_state_value() == 42