feat(conflicts): added conflicts business and presentation logic

This commit is contained in:
ITQ
2026-02-23 10:54:51 +03:00
parent d87671e49a
commit ace35b2585
19 changed files with 1283 additions and 86 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ConflictsApiConfig(AppConfig):
name = "api.v1.conflicts"
label = "api_v1_conflicts"
+219
View File
@@ -0,0 +1,219 @@
from http import HTTPStatus
from uuid import UUID
from django.http import Http404, HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.conflicts.schemas import (
AddExperimentIn,
ConflictDomainCreateIn,
ConflictDomainListOut,
ConflictDomainOut,
ConflictDomainUpdateIn,
ExperimentDomainOut,
MembershipOut,
UpdatePriorityIn,
)
from apps.conflicts.models import ConflictDomain
from apps.conflicts.selectors import (
conflict_domain_get,
conflict_domain_list,
domain_active_experiments,
experiment_conflict_domains,
)
from apps.conflicts.services import (
conflict_domain_create,
conflict_domain_delete,
conflict_domain_update,
experiment_add_to_domain,
experiment_remove_from_domain,
experiment_update_domain_priority,
)
from apps.experiments.models import Experiment
from apps.users.auth.bearer import jwt_bearer, require_admin
router = Router(tags=["conflicts"], auth=jwt_bearer)
@router.get(
"/domains",
response={HTTPStatus.OK: ConflictDomainListOut},
summary="List conflict domains",
)
def list_domains(
request: HttpRequest,
) -> tuple[HTTPStatus, ConflictDomainListOut]:
qs = conflict_domain_list()
items = [ConflictDomainOut.model_validate(d) for d in qs]
return HTTPStatus.OK, ConflictDomainListOut(count=len(items), items=items)
@router.post(
"/domains",
response={HTTPStatus.CREATED: ConflictDomainOut},
summary="Create conflict domain",
)
@require_admin
def create_domain(
request: HttpRequest,
payload: ConflictDomainCreateIn,
) -> tuple[HTTPStatus, ConflictDomainOut]:
domain = conflict_domain_create(
name=payload.name,
description=payload.description,
policy=payload.policy,
max_concurrent=payload.max_concurrent,
)
return HTTPStatus.CREATED, ConflictDomainOut.model_validate(domain)
@router.get(
"/domains/{domain_id}",
response={HTTPStatus.OK: ConflictDomainOut},
summary="Get conflict domain",
)
def get_domain(
request: HttpRequest,
domain_id: UUID,
) -> tuple[HTTPStatus, ConflictDomainOut]:
domain = conflict_domain_get(domain_id)
if not domain:
raise Http404
return HTTPStatus.OK, ConflictDomainOut.model_validate(domain)
@router.patch(
"/domains/{domain_id}",
response={HTTPStatus.OK: ConflictDomainOut},
summary="Update conflict domain",
)
@require_admin
def update_domain(
request: HttpRequest,
domain_id: UUID,
payload: ConflictDomainUpdateIn,
) -> tuple[HTTPStatus, ConflictDomainOut]:
domain = get_object_or_404(ConflictDomain, pk=domain_id)
domain = conflict_domain_update(
domain=domain,
**payload.model_dump(exclude_none=True),
)
return HTTPStatus.OK, ConflictDomainOut.model_validate(domain)
@router.delete(
"/domains/{domain_id}",
response={HTTPStatus.NO_CONTENT: None},
summary="Delete conflict domain",
)
@require_admin
def delete_domain(
request: HttpRequest,
domain_id: UUID,
) -> tuple[HTTPStatus, None]:
domain = get_object_or_404(ConflictDomain, pk=domain_id)
conflict_domain_delete(domain=domain)
return HTTPStatus.NO_CONTENT, None
@router.get(
"/domains/{domain_id}/experiments",
response={HTTPStatus.OK: list[MembershipOut]},
summary="List experiments in conflict domain",
)
def list_domain_experiments(
request: HttpRequest,
domain_id: UUID,
) -> tuple[HTTPStatus, list[MembershipOut]]:
get_object_or_404(ConflictDomain, pk=domain_id)
memberships = domain_active_experiments(domain_id)
return HTTPStatus.OK, [
MembershipOut.from_membership(m) for m in memberships
]
@router.post(
"/domains/{domain_id}/experiments",
response={HTTPStatus.CREATED: MembershipOut},
summary="Add experiment to conflict domain",
)
@require_admin
def add_experiment_to_domain(
request: HttpRequest,
domain_id: UUID,
payload: AddExperimentIn,
) -> tuple[HTTPStatus, MembershipOut]:
domain = get_object_or_404(ConflictDomain, pk=domain_id)
experiment = get_object_or_404(Experiment, pk=payload.experiment_id)
membership = experiment_add_to_domain(
experiment=experiment,
domain=domain,
priority=payload.priority,
)
membership = (
type(membership)
.objects.select_related("experiment", "conflict_domain")
.get(pk=membership.pk)
)
return HTTPStatus.CREATED, MembershipOut.from_membership(membership)
@router.patch(
"/domains/{domain_id}/experiments/{experiment_id}",
response={HTTPStatus.OK: MembershipOut},
summary="Update experiment priority in domain",
)
@require_admin
def update_experiment_priority(
request: HttpRequest,
domain_id: UUID,
experiment_id: UUID,
payload: UpdatePriorityIn,
) -> tuple[HTTPStatus, MembershipOut]:
domain = get_object_or_404(ConflictDomain, pk=domain_id)
experiment = get_object_or_404(Experiment, pk=experiment_id)
membership = experiment_update_domain_priority(
experiment=experiment,
domain=domain,
priority=payload.priority,
)
membership = (
type(membership)
.objects.select_related("experiment", "conflict_domain")
.get(pk=membership.pk)
)
return HTTPStatus.OK, MembershipOut.from_membership(membership)
@router.delete(
"/domains/{domain_id}/experiments/{experiment_id}",
response={HTTPStatus.NO_CONTENT: None},
summary="Remove experiment from conflict domain",
)
@require_admin
def remove_experiment_from_domain(
request: HttpRequest,
domain_id: UUID,
experiment_id: UUID,
) -> tuple[HTTPStatus, None]:
domain = get_object_or_404(ConflictDomain, pk=domain_id)
experiment = get_object_or_404(Experiment, pk=experiment_id)
experiment_remove_from_domain(experiment=experiment, domain=domain)
return HTTPStatus.NO_CONTENT, None
@router.get(
"/experiments/{experiment_id}/domains",
response={HTTPStatus.OK: list[ExperimentDomainOut]},
summary="List conflict domains for experiment",
)
def list_experiment_domains(
request: HttpRequest,
experiment_id: UUID,
) -> tuple[HTTPStatus, list[ExperimentDomainOut]]:
get_object_or_404(Experiment, pk=experiment_id)
memberships = experiment_conflict_domains(experiment_id)
return HTTPStatus.OK, [
ExperimentDomainOut.from_membership(m) for m in memberships
]
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from typing import ClassVar
from uuid import UUID
from ninja import Field, ModelSchema, Schema
from apps.conflicts.models import ConflictDomain, ConflictPolicy, ExperimentConflictDomain
class ConflictDomainOut(ModelSchema):
class Meta:
model = ConflictDomain
fields: ClassVar[tuple[str, ...]] = (
ConflictDomain.id.field.name,
ConflictDomain.name.field.name,
ConflictDomain.description.field.name,
ConflictDomain.policy.field.name,
ConflictDomain.max_concurrent.field.name,
ConflictDomain.created_at.field.name,
ConflictDomain.updated_at.field.name,
)
class ConflictDomainCreateIn(Schema):
name: str = Field(..., max_length=200)
description: str = ""
policy: ConflictPolicy = ConflictPolicy.MUTUAL_EXCLUSION
max_concurrent: int = Field(1, ge=1)
class ConflictDomainUpdateIn(Schema):
name: str | None = None
description: str | None = None
policy: ConflictPolicy | None = None
max_concurrent: int | None = Field(None, ge=1)
class ConflictDomainListOut(Schema):
count: int
items: list[ConflictDomainOut]
class ExperimentBriefOut(Schema):
id: UUID
name: str
status: str
class MembershipOut(Schema):
id: UUID
experiment: ExperimentBriefOut
priority: int
created_at: datetime
@classmethod
def from_membership(
cls, m: ExperimentConflictDomain,
) -> "MembershipOut":
return cls(
id=m.pk,
experiment=ExperimentBriefOut(
id=m.experiment.pk,
name=m.experiment.name,
status=m.experiment.status,
),
priority=m.priority,
created_at=m.created_at,
)
class ExperimentDomainOut(Schema):
id: UUID
conflict_domain: ConflictDomainOut
priority: int
created_at: datetime
@classmethod
def from_membership(
cls, m: ExperimentConflictDomain,
) -> "ExperimentDomainOut":
return cls(
id=m.pk,
conflict_domain=ConflictDomainOut.model_validate(
m.conflict_domain,
),
priority=m.priority,
created_at=m.created_at,
)
class AddExperimentIn(Schema):
experiment_id: UUID
priority: int = 0
class UpdatePriorityIn(Schema):
priority: int
@@ -0,0 +1,178 @@
import json
from typing import override
from django.test import Client, TestCase
from django.urls import reverse
from apps.conflicts.tests.helpers import make_domain
from apps.experiments.tests.helpers import add_two_variants, make_flag
from apps.experiments.services import experiment_create
from apps.reviews.tests.helpers import make_admin, make_experimenter, make_viewer
from apps.users.tests.helpers import auth_header
class ConflictDomainAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.admin = make_admin("_cda")
self.viewer = make_viewer("_cda")
self.admin_auth = auth_header(self.admin)
self.viewer_auth = auth_header(self.viewer)
def test_create_domain(self) -> None:
resp = self.client.post(
reverse("api-1:create_domain"),
data=json.dumps({
"name": "checkout",
"description": "Checkout zone",
"policy": "mutual_exclusion",
"max_concurrent": 1,
}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["name"], "checkout")
self.assertEqual(data["policy"], "mutual_exclusion")
def test_create_domain_viewer_forbidden(self) -> None:
resp = self.client.post(
reverse("api-1:create_domain"),
data=json.dumps({"name": "forbidden_zone"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertIn(resp.status_code, (403, 401))
def test_list_domains(self) -> None:
make_domain(suffix="_list1")
make_domain(suffix="_list2")
resp = self.client.get(
reverse("api-1:list_domains"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertGreaterEqual(data["count"], 2)
def test_get_domain(self) -> None:
domain = make_domain(suffix="_get")
resp = self.client.get(
reverse("api-1:get_domain", args=[domain.pk]),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["name"], domain.name)
def test_update_domain(self) -> None:
domain = make_domain(suffix="_upd")
resp = self.client.patch(
reverse("api-1:update_domain", args=[domain.pk]),
data=json.dumps({"description": "Updated desc"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["description"], "Updated desc")
def test_delete_domain(self) -> None:
domain = make_domain(suffix="_del")
resp = self.client.delete(
reverse("api-1:delete_domain", args=[domain.pk]),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 204)
def test_get_nonexistent_domain(self) -> None:
import uuid
resp = self.client.get(
reverse("api-1:get_domain", args=[uuid.uuid4()]),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
class DomainExperimentAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.admin = make_admin("_dea")
self.experimenter = make_experimenter("_dea")
self.admin_auth = auth_header(self.admin)
self.domain = make_domain(suffix="_dea")
self.flag = make_flag(suffix="_dea")
self.exp = experiment_create(
flag=self.flag,
name="DeaExp",
owner=self.experimenter,
)
add_two_variants(self.exp)
def test_add_experiment_to_domain(self) -> None:
resp = self.client.post(
reverse("api-1:add_experiment_to_domain", args=[self.domain.pk]),
data=json.dumps({
"experiment_id": str(self.exp.pk),
"priority": 5,
}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["priority"], 5)
self.assertEqual(data["experiment"]["id"], str(self.exp.pk))
def test_list_experiment_domains(self) -> None:
self.client.post(
reverse("api-1:add_experiment_to_domain", args=[self.domain.pk]),
data=json.dumps({"experiment_id": str(self.exp.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
resp = self.client.get(
reverse(
"api-1:list_experiment_domains", args=[self.exp.pk]
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()), 1)
def test_update_priority(self) -> None:
self.client.post(
reverse("api-1:add_experiment_to_domain", args=[self.domain.pk]),
data=json.dumps({"experiment_id": str(self.exp.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
resp = self.client.patch(
reverse(
"api-1:update_experiment_priority",
args=[self.domain.pk, self.exp.pk],
),
data=json.dumps({"priority": 42}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["priority"], 42)
def test_remove_experiment_from_domain(self) -> None:
self.client.post(
reverse("api-1:add_experiment_to_domain", args=[self.domain.pk]),
data=json.dumps({"experiment_id": str(self.exp.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
resp = self.client.delete(
reverse(
"api-1:remove_experiment_from_domain",
args=[self.domain.pk, self.exp.pk],
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 204)