Skip to content

Commit 48de147

Browse files
authored
fix: Feature.type field is unbounded (#6888)
1 parent c5953c2 commit 48de147

7 files changed

Lines changed: 165 additions & 8 deletions

File tree

api/features/feature_types.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
1-
from typing import Literal
1+
from typing import get_args
22

3-
FeatureType = Literal["STANDARD", "MULTIVARIATE"]
3+
from flagsmith_schemas.types import FeatureType
4+
5+
__all__ = (
6+
"FeatureType",
7+
"CONFIG",
8+
"FEATURE_TYPE_CHOICES",
9+
"FLAG",
10+
"MULTIVARIATE",
11+
"STANDARD",
12+
)
413

514
MULTIVARIATE: FeatureType = "MULTIVARIATE"
615
STANDARD: FeatureType = "STANDARD"
716

17+
FEATURE_TYPE_CHOICES = [(v, v) for v in get_args(FeatureType)]
18+
819
# the following two types have been merged in terms of functionality
920
# but kept for now until the FE is updated
1021
CONFIG = "CONFIG"
22+
"""Deprecated."""
1123
FLAG = "FLAG"
24+
"""Deprecated."""
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.apps.registry import Apps
2+
from django.db import migrations, models
3+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
4+
5+
from features.feature_types import FEATURE_TYPE_CHOICES, MULTIVARIATE, STANDARD
6+
7+
8+
def fix_feature_type(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
9+
Feature = apps.get_model("features", "Feature")
10+
# Features with multivariate options should be MULTIVARIATE.
11+
Feature.objects.filter(multivariate_options__isnull=False).exclude(
12+
type=MULTIVARIATE
13+
).update(type=MULTIVARIATE)
14+
# All remaining features with an invalid type should be STANDARD.
15+
Feature.objects.exclude(type__in=[STANDARD, MULTIVARIATE]).update(type=STANDARD)
16+
17+
18+
class Migration(migrations.Migration):
19+
dependencies = [
20+
("features", "0065_make_feature_value_size_configurable"),
21+
("multivariate", "0007_alter_boolean_values"),
22+
]
23+
24+
operations = [
25+
migrations.RunPython(fix_feature_type, reverse_code=migrations.RunPython.noop),
26+
migrations.AlterField(
27+
model_name="feature",
28+
name="type",
29+
field=models.CharField(
30+
blank=True,
31+
choices=FEATURE_TYPE_CHOICES,
32+
default=STANDARD,
33+
max_length=50,
34+
),
35+
),
36+
migrations.AlterField(
37+
model_name="historicalfeature",
38+
name="type",
39+
field=models.CharField(
40+
blank=True,
41+
choices=FEATURE_TYPE_CHOICES,
42+
default=STANDARD,
43+
max_length=50,
44+
),
45+
),
46+
]

api/features/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from features.constants import ENVIRONMENT, FEATURE_SEGMENT, IDENTITY
5656
from features.custom_lifecycle import CustomLifecycleModelMixin
5757
from features.feature_states.models import AbstractBaseFeatureValueModel
58-
from features.feature_types import MULTIVARIATE, STANDARD
58+
from features.feature_types import FEATURE_TYPE_CHOICES, MULTIVARIATE, STANDARD
5959
from features.helpers import get_correctly_typed_value
6060
from features.managers import (
6161
FeatureManager,
@@ -115,7 +115,9 @@ class Feature( # type: ignore[django-manager-missing]
115115
)
116116
description = models.TextField(null=True, blank=True)
117117
default_enabled = models.BooleanField(default=False)
118-
type = models.CharField(max_length=50, blank=True, default=STANDARD)
118+
type = models.CharField(
119+
max_length=50, blank=True, default=STANDARD, choices=FEATURE_TYPE_CHOICES
120+
)
119121
tags = models.ManyToManyField(Tag, blank=True)
120122
is_archived = models.BooleanField(default=False)
121123
owners = models.ManyToManyField(

api/features/serializers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,13 @@ class Meta:
224224
"last_modified_in_any_environment",
225225
"last_modified_in_current_environment",
226226
)
227-
read_only_fields = ("feature_segments", "created_date", "uuid", "project")
227+
read_only_fields = (
228+
"feature_segments",
229+
"created_date",
230+
"uuid",
231+
"project",
232+
"type",
233+
)
228234

229235
def to_internal_value(self, data): # type: ignore[no-untyped-def]
230236
if data.get("initial_value") and not isinstance(data["initial_value"], str):

api/tests/unit/features/test_migrations.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,54 @@ def test_fix_feature_type_migration(migrator: Migrator) -> None:
226226
assert NewFeature.objects.get(id=mv_feature.id).type == MULTIVARIATE
227227

228228

229+
def test_constrain_feature_type__invalid_types__fixes_to_standard_or_multivariate(
230+
migrator: Migrator,
231+
) -> None:
232+
# Given
233+
old_state = migrator.apply_initial_migration(
234+
("features", "0065_make_feature_value_size_configurable")
235+
)
236+
237+
Organisation = old_state.apps.get_model("organisations", "Organisation")
238+
Project = old_state.apps.get_model("projects", "Project")
239+
Feature = old_state.apps.get_model("features", "Feature")
240+
MultivariateFeatureOption = old_state.apps.get_model(
241+
"multivariate", "MultivariateFeatureOption"
242+
)
243+
244+
organisation = Organisation.objects.create(name="test org")
245+
project = Project.objects.create(
246+
name="test project", organisation_id=organisation.id
247+
)
248+
standard_feature = Feature.objects.create(
249+
name="standard_feature", project_id=project.id
250+
)
251+
mv_feature = Feature.objects.create(
252+
name="mv_feature", project_id=project.id, type=MULTIVARIATE
253+
)
254+
# Feature with an invalid type and no MV options → should become STANDARD.
255+
bad_type_feature = Feature.objects.create(
256+
name="bad_type_feature", project_id=project.id, type="boolean"
257+
)
258+
# Feature with an invalid type but has MV options → should become MULTIVARIATE.
259+
bad_type_mv_feature = Feature.objects.create(
260+
name="bad_type_mv_feature", project_id=project.id, type="flag"
261+
)
262+
MultivariateFeatureOption.objects.create(feature_id=bad_type_mv_feature.id)
263+
264+
# When
265+
new_state = migrator.apply_tested_migration(
266+
("features", "0066_constrain_feature_type")
267+
)
268+
269+
# Then
270+
NewFeature = new_state.apps.get_model("features", "Feature")
271+
assert NewFeature.objects.get(id=standard_feature.id).type == STANDARD
272+
assert NewFeature.objects.get(id=mv_feature.id).type == MULTIVARIATE
273+
assert NewFeature.objects.get(id=bad_type_feature.id).type == STANDARD
274+
assert NewFeature.objects.get(id=bad_type_mv_feature.id).type == MULTIVARIATE
275+
276+
229277
def test_migrate_sample_to_webhook_forward(migrator: Migrator) -> None:
230278
# Given
231279
old_state = migrator.apply_initial_migration(

api/tests/unit/features/test_unit_features_views.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from environments.permissions.models import UserEnvironmentPermission
4646
from features import views
4747
from features.dataclasses import EnvironmentFeatureOverridesData
48-
from features.feature_types import MULTIVARIATE
48+
from features.feature_types import MULTIVARIATE, STANDARD
4949
from features.models import Feature, FeatureSegment, FeatureState
5050
from features.multivariate.models import MultivariateFeatureOption
5151
from features.value_types import STRING
@@ -1816,8 +1816,8 @@ def test_audit_log_created_when_feature_updated(
18161816
args=[project.id, feature.id],
18171817
)
18181818
data = {
1819-
"name": "Test Feature updated",
1820-
"type": "FLAG",
1819+
"name": feature.name,
1820+
"description": "Updated description",
18211821
"project": project.id,
18221822
}
18231823

@@ -4488,3 +4488,42 @@ def test_list_features__edge_v2_project__makes_one_dynamo_query(
44884488
# Validate that only a single query is made to dynamodb, not one
44894489
# per feature.
44904490
assert mock_table.query.call_count == 1
4491+
4492+
4493+
def test_create_feature__type_provided__ignores_type_and_defaults_to_standard(
4494+
admin_client_new: APIClient,
4495+
project: Project,
4496+
) -> None:
4497+
# Given
4498+
url = reverse("api-v1:projects:project-features-list", args=[project.id])
4499+
data = {"name": "test_feature_type_readonly", "type": "boolean"}
4500+
4501+
# When
4502+
response = admin_client_new.post(
4503+
url, data=json.dumps(data), content_type="application/json"
4504+
)
4505+
4506+
# Then
4507+
assert response.status_code == status.HTTP_201_CREATED
4508+
assert response.json()["type"] == STANDARD
4509+
4510+
4511+
def test_create_feature__multivariate_options_provided__sets_type_to_multivariate(
4512+
admin_client_new: APIClient,
4513+
project: Project,
4514+
) -> None:
4515+
# Given
4516+
url = reverse("api-v1:projects:project-features-list", args=[project.id])
4517+
data = {
4518+
"name": "test_feature_mv_type",
4519+
"multivariate_options": [{"type": "unicode", "string_value": "option-a"}],
4520+
}
4521+
4522+
# When
4523+
response = admin_client_new.post(
4524+
url, data=json.dumps(data), content_type="application/json"
4525+
)
4526+
4527+
# Then
4528+
assert response.status_code == status.HTTP_201_CREATED
4529+
assert response.json()["type"] == MULTIVARIATE

sdk/openapi.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ paths:
150150
components:
151151
schemas:
152152
TypeD77Enum:
153+
description: |-
154+
* `STANDARD` - STANDARD
155+
* `MULTIVARIATE` - MULTIVARIATE
153156
type: string
154157
enum:
155158
- STANDARD

0 commit comments

Comments
 (0)