feat(decide): added decision API

This commit is contained in:
ITQ
2026-02-18 15:00:02 +03:00
parent cb4da51cf7
commit 66bff9e5d4
6 changed files with 153 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FlagsApiConfig(AppConfig):
name = "api.v1.decision"
label = "api_v1_decision"
+34
View File
@@ -0,0 +1,34 @@
from http import HTTPStatus
from django.http import HttpRequest
from ninja import Router
from api.v1.decision.schemas import DecisionIn, DecisionOut, SingleDecisionOut
from apps.decision.services import decide_for_flag
router = Router(tags=["decision"])
@router.post(
"",
response={HTTPStatus.OK: DecisionOut},
summary="Get decisions for feature flags",
description=(
"Resolve feature flag values for a given subject. "
"Returns default values when no active experiment applies, "
"or experiment variant values when the subject is assigned."
),
)
def decide(
request: HttpRequest,
payload: DecisionIn,
) -> tuple[HTTPStatus, DecisionOut]:
decisions = []
for flag_key in payload.flags:
result = decide_for_flag(
flag_key=flag_key,
subject_id=payload.subject_id,
subject_attributes=payload.subject_attributes,
)
decisions.append(SingleDecisionOut(**result))
return HTTPStatus.OK, DecisionOut(decisions=decisions)
+25
View File
@@ -0,0 +1,25 @@
from ninja import Field, Schema
class DecisionIn(Schema):
subject_id: str
subject_attributes: dict[str, str | int | float | bool] = Field(
default_factory=dict,
)
flags: list[str] = Field(
...,
min_length=1,
)
class SingleDecisionOut(Schema):
flag: str
value: str | int | float | bool | None
decision_id: str
experiment_id: str | None = None
variant_id: str | None = None
reason: str
class DecisionOut(Schema):
decisions: list[SingleDecisionOut]
@@ -0,0 +1,88 @@
import json
from decimal import Decimal
from typing import override
from django.core.cache import cache
from django.test import Client, TestCase
from django.urls import reverse
from apps.experiments.models import Experiment, ExperimentStatus
from apps.experiments.services import variant_create
from apps.experiments.tests.helpers import make_experiment, make_flag
from apps.users.tests.helpers import make_user
class DecisionAPITest(TestCase):
@override
def setUp(self) -> None:
cache.clear()
self.client = Client()
self.flag = make_flag(suffix="_dapi", default="default_val")
self.owner = make_user(
username="dapi_owner", email="dapi_owner@lotty.local"
)
def _decide(self, data):
return self.client.post(
reverse("api-1:decide"),
data=json.dumps(data),
content_type="application/json",
)
def test_unknown_flag(self) -> None:
resp = self._decide({"subject_id": "u1", "flags": ["nonexistent"]})
self.assertEqual(resp.status_code, 200)
decisions = resp.json()["decisions"]
self.assertEqual(len(decisions), 1)
self.assertEqual(decisions[0]["reason"], "flag_not_found")
def test_no_active_experiment(self) -> None:
resp = self._decide({"subject_id": "u1", "flags": [self.flag.key]})
self.assertEqual(resp.status_code, 200)
d = resp.json()["decisions"][0]
self.assertEqual(d["reason"], "no_active_experiment")
self.assertEqual(d["value"], "default_val")
def test_running_experiment(self) -> None:
exp = make_experiment(
flag=self.flag,
owner=self.owner,
suffix="_drun",
traffic_allocation=Decimal("100.00"),
)
variant_create(
experiment=exp,
name="control",
value="ctrl",
weight=Decimal("50.00"),
is_control=True,
)
variant_create(
experiment=exp,
name="treatment",
value="treat",
weight=Decimal("50.00"),
)
Experiment.objects.filter(pk=exp.pk).update(
status=ExperimentStatus.RUNNING
)
resp = self._decide({"subject_id": "user42", "flags": [self.flag.key]})
self.assertEqual(resp.status_code, 200)
d = resp.json()["decisions"][0]
self.assertEqual(d["reason"], "experiment_assigned")
self.assertIn(d["value"], ("ctrl", "treat"))
def test_multiple_flags(self) -> None:
flag2 = make_flag(suffix="_dapi2", default="other")
resp = self._decide(
{
"subject_id": "u1",
"flags": [self.flag.key, flag2.key],
}
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()["decisions"]), 2)
def test_empty_flags_rejected(self) -> None:
resp = self._decide({"subject_id": "u1", "flags": []})
self.assertEqual(resp.status_code, 422)