feat(conflicts): added conflicts business and presentation logic
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConflictsApiConfig(AppConfig):
|
||||
name = "api.v1.conflicts"
|
||||
label = "api_v1_conflicts"
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user