Coverage for src/case_router/service.py: 100%
48 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
1"""Use cases: submit, read, list, and close cases, plus the queue health report."""
3from __future__ import annotations
5from collections.abc import Callable
6from datetime import UTC, datetime
8from .db import CaseRepository, Database, RuleRepository, TenantRepository
9from .deadlines import ResponsePolicy
10from .errors import UnknownTenant
11from .metrics import Metrics
12from .models import Case, CaseDraft, QueueHealth, Status, Tenant
13from .rules import RuleSet, build_rule
15UtcClock = Callable[[], datetime]
18def utc_now() -> datetime:
19 return datetime.now(UTC)
22class CaseService:
23 def __init__(
24 self,
25 db: Database,
26 *,
27 metrics: Metrics,
28 policy: ResponsePolicy | None = None,
29 clock: UtcClock = utc_now,
30 ) -> None:
31 self.tenants = TenantRepository(db)
32 self.rules = RuleRepository(db)
33 self.cases = CaseRepository(db)
34 self.metrics = metrics
35 self.policy = policy or ResponsePolicy()
36 self._clock = clock
38 def tenant(self, tenant_id: str) -> Tenant:
39 tenant = self.tenants.get(tenant_id)
40 if tenant is None:
41 raise UnknownTenant(tenant_id)
42 return tenant
44 def rule_set(self, tenant_id: str) -> RuleSet:
45 rows = self.rules.for_tenant(tenant_id)
46 return RuleSet([build_rule(r.kind, r.value, r.queue) for r in rows])
48 def submit(self, tenant: Tenant, draft: CaseDraft) -> Case:
49 with self.metrics.routing_seconds.labels(tenant.id).time():
50 decision = self.rule_set(tenant.id).route(draft)
51 created_at = self._clock()
52 respond_by = self.policy.respond_by(created_at, draft.priority, draft.tier)
53 case = self.cases.insert(tenant.id, draft, decision, created_at, respond_by)
54 self.metrics.cases_routed.labels(tenant.id, decision.queue, decision.rule).inc()
55 return case
57 def get(self, tenant: Tenant, case_id: int) -> Case:
58 return self.cases.get(tenant.id, case_id)
60 def find(
61 self, tenant: Tenant, *, queue: str | None, status: Status | None, limit: int
62 ) -> list[Case]:
63 return self.cases.find(tenant.id, queue=queue, status=status, limit=limit)
65 def close(self, tenant: Tenant, case_id: int) -> Case:
66 case = self.cases.close(tenant.id, case_id, self._clock())
67 assert case.closed_at is not None
68 on_time = "yes" if case.closed_at <= case.respond_by else "no"
69 self.metrics.cases_closed.labels(tenant.id, case.queue, on_time).inc()
70 return case
72 def queue_health(self, tenant: Tenant) -> list[QueueHealth]:
73 return self.cases.queue_health(tenant.id, self._clock())