Skip to content

Commit b987697

Browse files
committed
cleanup
1 parent db2477e commit b987697

File tree

6 files changed

+93
-105
lines changed

6 files changed

+93
-105
lines changed

api/features/feature_states/serializers.py

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import typing
2-
3-
from django.db.models import Q
41
from rest_framework import serializers
52

63
from environments.models import Environment
@@ -22,37 +19,34 @@ def get_feature(self) -> Feature:
2219
if not environment:
2320
raise serializers.ValidationError("Environment context is required")
2421

22+
# FeatureIdentifierSerializer validates exactly one of id/name is present
23+
feature_id = feature_data.get("id")
24+
feature_name = feature_data.get("name")
25+
2526
try:
26-
# TODO: strip out the id vs name piece as this is ugly as heck.
27-
query = Q(project_id=environment.project_id) & (
28-
Q(id=feature_data.get("id")) | Q(name=feature_data.get("name"))
27+
if feature_id is not None:
28+
feature: Feature = Feature.objects.get(
29+
project_id=environment.project_id, id=feature_id
30+
)
31+
return feature
32+
feature = Feature.objects.get(
33+
project_id=environment.project_id, name=feature_name
2934
)
30-
feature: Feature = Feature.objects.get(query)
3135
return feature
3236
except Feature.DoesNotExist:
33-
identifier = feature_data.get("id") or feature_data.get("name")
3437
raise serializers.ValidationError(
35-
f"Feature with identifier '{identifier}' not found in project"
38+
f"Feature with identifier '{feature_id or feature_name}' not found in project"
3639
)
3740

38-
def _add_audit_fields(
39-
self, change_set: typing.Union[FlagChangeSet, FlagChangeSetV2]
40-
) -> None:
41+
def _add_audit_fields(self, change_set: FlagChangeSet | FlagChangeSetV2) -> None:
4142
request = self.context["request"]
4243
if type(request.user) is FFAdminUser:
4344
change_set.user = request.user
4445
else:
4546
change_set.api_key = request.user.key
4647

47-
def _parse_feature_value(self, value_data: dict) -> typing.Any: # type: ignore[type-arg]
48-
value_serializer = FeatureValueSerializer(data=value_data)
49-
value_serializer.is_valid()
50-
return value_serializer.get_typed_value()
51-
5248

5349
class FeatureIdentifierSerializer(serializers.Serializer): # type: ignore[type-arg]
54-
"""Accepts either name OR id (mutually exclusive)."""
55-
5650
name = serializers.CharField(required=False, allow_blank=False)
5751
id = serializers.IntegerField(required=False)
5852

@@ -77,19 +71,24 @@ class FeatureValueSerializer(serializers.Serializer): # type: ignore[type-arg]
7771
)
7872
string_value = serializers.CharField(required=True, allow_blank=True)
7973

80-
def get_typed_value(self) -> typing.Any:
81-
value_type = self.validated_data["type"]
82-
string_val = self.validated_data["string_value"]
74+
def validate(self, data: dict) -> dict: # type: ignore[type-arg]
75+
value_type = data["type"]
76+
string_val = data["string_value"]
77+
78+
if value_type == "integer":
79+
try:
80+
int(string_val)
81+
except ValueError:
82+
raise serializers.ValidationError(
83+
f"'{string_val}' is not a valid integer"
84+
)
85+
elif value_type == "boolean":
86+
if string_val.lower() not in ("true", "false"):
87+
raise serializers.ValidationError(
88+
f"'{string_val}' is not a valid boolean (use 'true' or 'false')"
89+
)
8390

84-
match value_type:
85-
case "string":
86-
return string_val
87-
case "integer":
88-
return int(string_val)
89-
case "boolean":
90-
return string_val.lower() == "true"
91-
case _:
92-
raise ValueError(f"Unsupported value type: {value_type}")
91+
return data
9392

9493

9594
class UpdateFlagSerializer(BaseFeatureUpdateSerializer):
@@ -106,7 +105,7 @@ def flag_change_set(self) -> FlagChangeSet:
106105

107106
change_set = FlagChangeSet(
108107
enabled=validated_data["enabled"],
109-
feature_state_value=self._parse_feature_value(value_data),
108+
feature_state_value=value_data["string_value"],
110109
type_=value_data["type"],
111110
segment_id=segment_data.get("id") if segment_data else None,
112111
segment_priority=segment_data.get("priority") if segment_data else None,
@@ -115,9 +114,9 @@ def flag_change_set(self) -> FlagChangeSet:
115114
self._add_audit_fields(change_set)
116115
return change_set
117116

118-
def save(self, **kwargs: typing.Any) -> FeatureState:
119-
environment: Environment = kwargs["environment"]
120-
feature: Feature = self.get_feature()
117+
def save(self, **kwargs: object) -> FeatureState:
118+
environment: Environment = kwargs["environment"] # type: ignore[assignment]
119+
feature = self.get_feature()
121120

122121
return update_flag(environment, feature, self.flag_change_set)
123122

@@ -170,24 +169,24 @@ def change_set_v2(self) -> FlagChangeSetV2:
170169
segment_override = SegmentOverrideChangeSet(
171170
segment_id=override_data["segment_id"],
172171
enabled=override_data["enabled"],
173-
feature_state_value=self._parse_feature_value(value_data),
172+
feature_state_value=value_data["string_value"],
174173
type_=value_data["type"],
175174
priority=override_data.get("priority"),
176175
)
177176
segment_overrides.append(segment_override)
178177

179178
change_set = FlagChangeSetV2(
180179
environment_default_enabled=env_default["enabled"],
181-
environment_default_value=self._parse_feature_value(env_value_data),
180+
environment_default_value=env_value_data["string_value"],
182181
environment_default_type=env_value_data["type"],
183182
segment_overrides=segment_overrides,
184183
)
185184

186185
self._add_audit_fields(change_set)
187186
return change_set
188187

189-
def save(self, **kwargs: typing.Any) -> dict: # type: ignore[type-arg]
190-
environment: Environment = kwargs["environment"]
191-
feature: Feature = self.get_feature()
188+
def save(self, **kwargs: object) -> dict: # type: ignore[type-arg]
189+
environment: Environment = kwargs["environment"] # type: ignore[assignment]
190+
feature = self.get_feature()
192191

193192
return update_flag_v2(environment, feature, self.change_set_v2)

api/features/feature_states/views.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,10 @@ def _check_workflow_not_enabled(environment: Environment) -> None:
3636
**Value Format:**
3737
- Always use `string_value` field (value is always a string)
3838
- The `type` field tells the server how to parse it
39-
- Available types: integer, string, boolean, float
39+
- Available types: integer, string, boolean
4040
- Examples:
4141
- `{"type": "integer", "string_value": "42"}`
4242
- `{"type": "boolean", "string_value": "true"}`
43-
- `{"type": "float", "string_value": "3.14"}`
4443
- `{"type": "string", "string_value": "hello"}`
4544
4645
**Segment Priority:**
@@ -69,11 +68,7 @@ def update_flag_v1(request: Request, environment_id: int) -> Response:
6968

7069
serializer = UpdateFlagSerializer(
7170
data=request.data,
72-
context={
73-
"request": request,
74-
"view": update_flag_v1,
75-
"environment": environment,
76-
},
71+
context={"request": request, "environment": environment},
7772
)
7873
serializer.is_valid(raise_exception=True)
7974
serializer.save(environment=environment)
@@ -103,7 +98,7 @@ def update_flag_v1(request: Request, environment_id: int) -> Response:
10398
**Value Format:**
10499
- Always use `string_value` field (value is always a string)
105100
- The `type` field tells the server how to parse it
106-
- Available types: integer, string, boolean, float
101+
- Available types: integer, string, boolean
107102
- Examples:
108103
- `{"type": "string", "string_value": "production"}`
109104
- `{"type": "integer", "string_value": "100"}`
@@ -138,11 +133,7 @@ def update_flag_v2(request: Request, environment_id: int) -> Response:
138133

139134
serializer = UpdateFlagV2Serializer(
140135
data=request.data,
141-
context={
142-
"request": request,
143-
"view": update_flag_v2,
144-
"environment": environment,
145-
},
136+
context={"request": request, "environment": environment},
146137
)
147138
serializer.is_valid(raise_exception=True)
148139
serializer.save(environment=environment)

api/features/versioning/dataclasses.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import typing
1+
from __future__ import annotations
2+
23
from dataclasses import dataclass, field
34
from datetime import datetime
5+
from typing import TYPE_CHECKING
46

57
from pydantic import BaseModel, computed_field
68

7-
if typing.TYPE_CHECKING:
8-
from api_keys.models import MasterAPIKey
9+
if TYPE_CHECKING:
910
from users.models import FFAdminUser
1011

1112

@@ -22,14 +23,14 @@ def is_environment_default(self) -> bool:
2223

2324
@dataclass
2425
class AuditFieldsMixin:
25-
user: typing.Optional["FFAdminUser"] = field(default=None, kw_only=True)
26-
api_key: typing.Optional["MasterAPIKey"] = field(default=None, kw_only=True)
26+
user: FFAdminUser | None = field(default=None, kw_only=True)
27+
api_key: str | None = field(default=None, kw_only=True)
2728

2829

2930
@dataclass
3031
class FlagChangeSet(AuditFieldsMixin):
3132
enabled: bool
32-
feature_state_value: typing.Any
33+
feature_state_value: str
3334
type_: str
3435

3536
segment_id: int | None = None
@@ -40,15 +41,15 @@ class FlagChangeSet(AuditFieldsMixin):
4041
class SegmentOverrideChangeSet:
4142
segment_id: int
4243
enabled: bool
43-
feature_state_value: typing.Any
44+
feature_state_value: str
4445
type_: str
4546
priority: int | None = None
4647

4748

4849
@dataclass
4950
class FlagChangeSetV2(AuditFieldsMixin):
5051
environment_default_enabled: bool
51-
environment_default_value: typing.Any
52+
environment_default_value: str
5253
environment_default_type: str
5354

5455
segment_overrides: list[SegmentOverrideChangeSet] = field(default_factory=list)

api/features/versioning/versioning_service.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,18 +239,16 @@ def _update_flag_for_versioning_v1(
239239
return target_feature_state
240240

241241

242-
def _update_feature_state_value(
243-
fsv: FeatureStateValue, value: typing.Any, type_: str
244-
) -> None:
242+
def _update_feature_state_value(fsv: FeatureStateValue, value: str, type_: str) -> None:
245243
match type_:
246244
case "string":
247-
fsv.string_value = str(value)
245+
fsv.string_value = value
248246
fsv.type = STRING
249247
case "integer":
250248
fsv.integer_value = int(value)
251249
fsv.type = INTEGER
252250
case "boolean":
253-
fsv.boolean_value = value
251+
fsv.boolean_value = value.lower() == "true"
254252
fsv.type = BOOLEAN
255253

256254
fsv.save()

api/tests/unit/features/feature_states/test_serializers.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,17 @@ def test_validate_segment_overrides_returns_empty_list() -> None:
6969
assert result == []
7070

7171

72-
def test_get_typed_value_raises_error_for_unsupported_type() -> None:
73-
serializer = FeatureValueSerializer()
74-
# Bypass validation by setting validated_data directly
75-
serializer._validated_data = {"type": "unsupported", "string_value": "test"} # type: ignore[attr-defined]
72+
def test_feature_value_serializer_rejects_invalid_integer() -> None:
73+
serializer = FeatureValueSerializer(
74+
data={"type": "integer", "string_value": "not_a_number"}
75+
)
76+
77+
assert serializer.is_valid() is False
78+
assert "not a valid integer" in str(serializer.errors)
79+
7680

77-
with pytest.raises(ValueError) as exc_info:
78-
serializer.get_typed_value()
81+
def test_feature_value_serializer_rejects_invalid_boolean() -> None:
82+
serializer = FeatureValueSerializer(data={"type": "boolean", "string_value": "yes"})
7983

80-
assert "Unsupported value type: unsupported" in str(exc_info.value)
84+
assert serializer.is_valid() is False
85+
assert "not a valid boolean" in str(serializer.errors)

api/tests/unit/features/feature_states/test_unit_feature_states_views.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from rest_framework.test import APIClient
1010

1111
from environments.models import Environment
12-
from features.models import Feature, FeatureSegment
12+
from features.models import Feature, FeatureSegment, FeatureState
1313
from features.versioning.versioning_service import (
1414
get_environment_flags_list,
1515
)
@@ -285,9 +285,6 @@ def test_update_flag_error_when_feature_not_found_by_id(
285285
assert "Feature with identifier '999999' not found" in str(response.json())
286286

287287

288-
# V1 Segment Override Tests
289-
290-
291288
@pytest.mark.parametrize(
292289
"environment_",
293290
(lazy_fixture("environment"), lazy_fixture("environment_v2_versioning")),
@@ -598,23 +595,25 @@ def test_update_existing_segment_override_with_priority_v1(
598595

599596
segment = Segment.objects.create(name="priority_segment", project=project)
600597

598+
# Create the segment override manually
599+
feature_segment = FeatureSegment.objects.create(
600+
feature=feature,
601+
segment=segment,
602+
environment=environment_,
603+
priority=5,
604+
)
605+
FeatureState.objects.create(
606+
feature=feature,
607+
environment=environment_,
608+
feature_segment=feature_segment,
609+
enabled=True,
610+
)
611+
601612
url = reverse(
602613
"api-experiments:update-flag-v1",
603614
kwargs={"environment_id": environment_.id},
604615
)
605616

606-
# First, create the segment override
607-
create_data = {
608-
"feature": {"name": feature.name},
609-
"segment": {"id": segment.id, "priority": 5},
610-
"enabled": True,
611-
"value": {"type": "integer", "string_value": "100"},
612-
}
613-
response = staff_client.post(
614-
url, data=json.dumps(create_data), content_type="application/json"
615-
)
616-
assert response.status_code == status.HTTP_204_NO_CONTENT
617-
618617
# When - Update the same segment override with new priority
619618
update_data = {
620619
"feature": {"name": feature.name},
@@ -658,21 +657,19 @@ def test_update_existing_segment_override_with_priority_v2(
658657

659658
segment = Segment.objects.create(name="priority_segment_v2", project=project)
660659

661-
# First, create the segment override using V1 endpoint
662-
v1_url = reverse(
663-
"api-experiments:update-flag-v1",
664-
kwargs={"environment_id": environment_.id},
660+
# Create the segment override manually
661+
feature_segment = FeatureSegment.objects.create(
662+
feature=feature,
663+
segment=segment,
664+
environment=environment_,
665+
priority=5,
665666
)
666-
create_data = {
667-
"feature": {"name": feature.name},
668-
"segment": {"id": segment.id, "priority": 5},
669-
"enabled": True,
670-
"value": {"type": "string", "string_value": "initial"},
671-
}
672-
response = staff_client.post(
673-
v1_url, data=json.dumps(create_data), content_type="application/json"
667+
FeatureState.objects.create(
668+
feature=feature,
669+
environment=environment_,
670+
feature_segment=feature_segment,
671+
enabled=True,
674672
)
675-
assert response.status_code == status.HTTP_204_NO_CONTENT
676673

677674
# When - Update the existing segment override using V2 endpoint
678675
v2_url = reverse(
@@ -713,9 +710,6 @@ def test_update_existing_segment_override_with_priority_v2(
713710
assert feature_segment.priority == 2
714711

715712

716-
# Workflow enabled tests
717-
718-
719713
def test_update_flag_v1_returns_403_when_workflow_enabled(
720714
staff_client: APIClient,
721715
feature: Feature,

0 commit comments

Comments
 (0)