feat(flags): added feature flags business and presentation logic

This commit is contained in:
ITQ
2026-02-14 10:59:04 +03:00
parent d4a3876147
commit 10c0ba01db
15 changed files with 1148 additions and 0 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FlagsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.flags"
@@ -0,0 +1,34 @@
# Generated by Django 5.2.11 on 2026-02-13 07:57
import django.core.validators
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='FeatureFlag',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('key', models.CharField(help_text='Unique identifier for the feature flag', max_length=100, unique=True, validators=[django.core.validators.RegexValidator(message='Key must start with a lowercase letter and contain only lowercase letters, digits, and underscores.', regex='^[a-z][a-z0-9_]*$')], verbose_name='key')),
('name', models.CharField(help_text='Human-readable name for the feature flag', max_length=200, verbose_name='name')),
('value_type', models.CharField(choices=[('string', 'String'), ('boolean', 'Boolean'), ('integer', 'Integer')], default='boolean', help_text='Data type of the feature flag value', max_length=20, verbose_name='flag type')),
('default_value', models.CharField(help_text='Default value for the feature flag', max_length=200, verbose_name='default value')),
('description', models.TextField(blank=True, help_text='Optional description of the feature flag', verbose_name='description')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'feature flag',
'verbose_name_plural': 'feature flags',
'ordering': ['-created_at'],
},
),
]
+116
View File
@@ -0,0 +1,116 @@
from typing import override
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.core.models import BaseModel
KEY_PATTERN = r"^[a-z][a-z0-9_]*$"
class FeatureFlagType(models.TextChoices):
STRING = "string", _("String")
BOOLEAN = "boolean", _("Boolean")
INTEGER = "integer", _("Integer")
BOOLEAN_TRUE_VALUES = frozenset({"true", "1", "yes"})
BOOLEAN_FALSE_VALUES = frozenset({"false", "0", "no"})
BOOLEAN_ALLOWED_VALUES = BOOLEAN_TRUE_VALUES | BOOLEAN_FALSE_VALUES
def validate_value_for_type(value: str, value_type: str) -> str:
if value_type == FeatureFlagType.BOOLEAN:
if value.lower() not in BOOLEAN_ALLOWED_VALUES:
raise ValidationError(
{
"default_value": (
f"Boolean flag value must be one of: "
f"{', '.join(sorted(BOOLEAN_ALLOWED_VALUES))}. "
f"Got '{value}'."
)
}
)
return "true" if value.lower() in BOOLEAN_TRUE_VALUES else "false"
if value_type == FeatureFlagType.INTEGER:
try:
int(value)
except (ValueError, TypeError):
raise ValidationError(
{
"default_value": (
f"Integer flag value must be a valid integer. "
f"Got '{value}'."
)
}
) from None
return str(int(value))
return value
class FeatureFlag(BaseModel):
key = models.CharField(
max_length=100,
unique=True,
verbose_name=_("key"),
help_text=_("Unique identifier for the feature flag"),
validators=[
RegexValidator(
regex=KEY_PATTERN,
message=(
"Key must start with a lowercase letter and contain only "
"lowercase letters, digits, and underscores."
),
)
],
)
name = models.CharField(
max_length=200,
verbose_name=_("name"),
help_text=_("Human-readable name for the feature flag"),
)
value_type = models.CharField(
max_length=20,
choices=FeatureFlagType.choices,
default=FeatureFlagType.BOOLEAN,
verbose_name=_("flag type"),
help_text=_("Data type of the feature flag value"),
)
default_value = models.CharField(
max_length=200,
verbose_name=_("default value"),
help_text=_("Default value for the feature flag"),
)
description = models.TextField(
blank=True,
verbose_name=_("description"),
help_text=_("Optional description of the feature flag"),
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created at"),
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("updated at"),
)
class Meta:
verbose_name = _("feature flag")
verbose_name_plural = _("feature flags")
ordering = ["-created_at"]
@override
def __str__(self) -> str:
return f"{self.key} ({self.value_type})"
@override
def clean(self) -> None:
super().clean()
self.default_value = validate_value_for_type(
self.default_value, self.value_type
)
+23
View File
@@ -0,0 +1,23 @@
from django.db.models import QuerySet
from apps.flags.models import FeatureFlag
def feature_flag_list(
*,
value_type: str | None = None,
search: str | None = None,
) -> QuerySet[FeatureFlag]:
qs = FeatureFlag.objects.all()
if value_type:
qs = qs.filter(value_type=value_type)
if search:
qs = qs.filter(key__icontains=search)
return qs
def feature_flag_get_by_key(key: str) -> FeatureFlag | None:
return FeatureFlag.objects.filter(key=key).first()
+41
View File
@@ -0,0 +1,41 @@
from django.core.exceptions import ValidationError
from apps.flags.models import FeatureFlag, FeatureFlagType
def feature_flag_create(
*,
key: str,
name: str,
value_type: str,
default_value: str,
) -> FeatureFlag:
valid_types = {choice[0] for choice in FeatureFlagType.choices}
if value_type not in valid_types:
raise ValidationError(
{
"value_type": (
f"Invalid flag type '{value_type}'. "
f"Must be one of: {', '.join(sorted(valid_types))}"
)
}
)
flag = FeatureFlag(
key=key,
name=name,
value_type=value_type,
default_value=default_value,
)
flag.save()
return flag
def feature_flag_update_default(
*,
flag: FeatureFlag,
default_value: str,
) -> FeatureFlag:
flag.default_value = default_value
flag.save(update_fields=["default_value", "updated_at"])
return flag
+1
View File
@@ -0,0 +1 @@
+213
View File
@@ -0,0 +1,213 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.flags.models import (
FeatureFlagType,
validate_value_for_type,
)
from apps.flags.selectors import feature_flag_get_by_key, feature_flag_list
from apps.flags.services import (
feature_flag_create,
feature_flag_update_default,
)
from config.errors import ConflictError
class ValidateValueForTypeTest(TestCase):
def test_boolean_true_variants(self) -> None:
for val in ("true", "True", "1", "yes", "YES"):
self.assertEqual(
validate_value_for_type(val, FeatureFlagType.BOOLEAN), "true"
)
def test_boolean_false_variants(self) -> None:
for val in ("false", "False", "0", "no", "NO"):
self.assertEqual(
validate_value_for_type(val, FeatureFlagType.BOOLEAN), "false"
)
def test_boolean_invalid(self) -> None:
with self.assertRaises(ValidationError):
validate_value_for_type("maybe", FeatureFlagType.BOOLEAN)
def test_integer_valid(self) -> None:
self.assertEqual(
validate_value_for_type("42", FeatureFlagType.INTEGER), "42"
)
def test_integer_negative(self) -> None:
self.assertEqual(
validate_value_for_type("-7", FeatureFlagType.INTEGER), "-7"
)
def test_integer_invalid(self) -> None:
with self.assertRaises(ValidationError):
validate_value_for_type("abc", FeatureFlagType.INTEGER)
def test_integer_float(self) -> None:
with self.assertRaises(ValidationError):
validate_value_for_type("3.14", FeatureFlagType.INTEGER)
def test_string_passthrough(self) -> None:
self.assertEqual(
validate_value_for_type("anything", FeatureFlagType.STRING),
"anything",
)
class FeatureFlagServiceTest(TestCase):
def test_create_string_flag(self) -> None:
flag = feature_flag_create(
key="svc_string",
name="Service String",
value_type=FeatureFlagType.STRING,
default_value="hello",
)
self.assertEqual(flag.key, "svc_string")
self.assertEqual(flag.value_type, FeatureFlagType.STRING)
self.assertEqual(flag.default_value, "hello")
def test_create_boolean_normalises(self) -> None:
flag = feature_flag_create(
key="svc_bool",
name="Service Bool",
value_type=FeatureFlagType.BOOLEAN,
default_value="Yes",
)
self.assertEqual(flag.default_value, "true")
def test_create_integer_normalises(self) -> None:
flag = feature_flag_create(
key="svc_int",
name="Service Int",
value_type=FeatureFlagType.INTEGER,
default_value="007",
)
self.assertEqual(flag.default_value, "7")
def test_create_invalid_type_raises(self) -> None:
with self.assertRaises(ValidationError):
feature_flag_create(
key="svc_bad",
name="Bad",
value_type="float",
default_value="1.0",
)
def test_create_bool_invalid_value_raises(self) -> None:
with self.assertRaises(ValidationError):
feature_flag_create(
key="svc_bad_bool",
name="Bad Bool",
value_type=FeatureFlagType.BOOLEAN,
default_value="maybe",
)
def test_create_int_invalid_value_raises(self) -> None:
with self.assertRaises(ValidationError):
feature_flag_create(
key="svc_bad_int",
name="Bad Int",
value_type=FeatureFlagType.INTEGER,
default_value="abc",
)
def test_create_duplicate_key_raises(self) -> None:
feature_flag_create(
key="svc_dup",
name="First",
value_type=FeatureFlagType.STRING,
default_value="a",
)
with self.assertRaises(ConflictError):
feature_flag_create(
key="svc_dup",
name="Second",
value_type=FeatureFlagType.STRING,
default_value="b",
)
def test_update_default_value(self) -> None:
flag = feature_flag_create(
key="svc_upd",
name="Update",
value_type=FeatureFlagType.STRING,
default_value="old",
)
updated = feature_flag_update_default(flag=flag, default_value="new")
self.assertEqual(updated.default_value, "new")
flag.refresh_from_db()
self.assertEqual(flag.default_value, "new")
def test_update_bool_validates(self) -> None:
flag = feature_flag_create(
key="svc_upd_bool",
name="Update Bool",
value_type=FeatureFlagType.BOOLEAN,
default_value="true",
)
with self.assertRaises(ValidationError):
feature_flag_update_default(flag=flag, default_value="maybe")
def test_update_int_validates(self) -> None:
flag = feature_flag_create(
key="svc_upd_int",
name="Update Int",
value_type=FeatureFlagType.INTEGER,
default_value="5",
)
with self.assertRaises(ValidationError):
feature_flag_update_default(flag=flag, default_value="bad")
class FeatureFlagSelectorTest(TestCase):
def test_list_all(self) -> None:
feature_flag_create(
key="sel_a", name="A", value_type="string", default_value="a"
)
feature_flag_create(
key="sel_b", name="B", value_type="boolean", default_value="true"
)
self.assertEqual(feature_flag_list().count(), 2)
def test_list_filter_type(self) -> None:
feature_flag_create(
key="sel_ft_s",
name="S",
value_type="string",
default_value="s",
)
feature_flag_create(
key="sel_ft_b",
name="B",
value_type="boolean",
default_value="true",
)
qs = feature_flag_list(value_type="boolean")
self.assertEqual(qs.count(), 1)
self.assertEqual(qs.first().key, "sel_ft_b")
def test_list_search(self) -> None:
feature_flag_create(
key="sel_search_match",
name="M",
value_type="string",
default_value="m",
)
feature_flag_create(
key="sel_other", name="O", value_type="string", default_value="o"
)
qs = feature_flag_list(search="match")
self.assertEqual(qs.count(), 1)
def test_get_by_key(self) -> None:
feature_flag_create(
key="sel_get", name="G", value_type="string", default_value="g"
)
flag = feature_flag_get_by_key("sel_get")
self.assertIsNotNone(flag)
self.assertEqual(flag.key, "sel_get")
def test_get_by_key_not_found(self) -> None:
flag = feature_flag_get_by_key("nonexistent")
self.assertIsNone(flag)