feat(tickets): add optional integration contracts and WebUI composition
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 12:06:04 +02:00
parent 0c1358b862
commit a090e5af20
11 changed files with 868 additions and 428 deletions
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Mapping, Protocol, runtime_checkable
TICKET_INTEGRATION_CONTRACT_VERSION = "1"
CAPABILITY_TICKET_ROUTING = "tickets.routing"
CAPABILITY_TICKET_CASE_ESCALATION = "tickets.case_escalation"
@dataclass(frozen=True, slots=True)
class TicketRoutingRequest:
tenant_id: str
ticket_id: str
ticket_type: str
priority: str
title: str
received_at: datetime
queue_hint: str | None = None
attributes: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.tenant_id, "Ticket routing tenant", 255)
_required(self.ticket_id, "Ticket routing ticket", 255)
_required(self.ticket_type, "Ticket routing type", 80)
_required(self.priority, "Ticket routing priority", 40)
_required(self.title, "Ticket routing title", 500)
_aware(self.received_at, "Ticket routing received_at")
_optional(self.queue_hint, "Ticket routing queue hint", 255)
if len(self.attributes) > 100:
raise ValueError("Ticket routing attributes are limited to 100 entries.")
@dataclass(frozen=True, slots=True)
class TicketRoutingPlan:
provider_id: str
queue_ref: str | None = None
service_target_at: datetime | None = None
explanation: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.provider_id, "Ticket routing provider", 200)
_optional(self.queue_ref, "Ticket routing queue reference", 255)
_optional(self.explanation, "Ticket routing explanation", 4_000)
_aware(self.service_target_at, "Ticket routing service_target_at")
if len(self.metadata) > 100:
raise ValueError("Ticket routing metadata is limited to 100 entries.")
@runtime_checkable
class TicketRoutingProvider(Protocol):
def route_ticket(
self,
session: object,
principal: object,
*,
request: TicketRoutingRequest,
) -> TicketRoutingPlan: ...
@dataclass(frozen=True, slots=True)
class TicketCaseEscalationCommand:
tenant_id: str
ticket_id: str
ticket_number: str
title: str
case_type_key: str
occurred_at: datetime
idempotency_key: str
handoff_note: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.tenant_id, "Ticket escalation tenant", 255)
_required(self.ticket_id, "Ticket escalation ticket", 255)
_required(self.ticket_number, "Ticket escalation number", 255)
_required(self.title, "Ticket escalation title", 500)
_required(self.case_type_key, "Ticket escalation case type", 120)
_required(self.idempotency_key, "Ticket escalation idempotency key", 255)
_optional(self.handoff_note, "Ticket escalation handoff note", 10_000)
_aware(self.occurred_at, "Ticket escalation occurred_at")
if len(self.metadata) > 100:
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
@dataclass(frozen=True, slots=True)
class TicketCaseEscalationResult:
provider_id: str
case_id: str
case_number: str
case_url: str
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.provider_id, "Ticket escalation provider", 200)
_required(self.case_id, "Ticket escalation case", 255)
_required(self.case_number, "Ticket escalation case number", 255)
_relative_url(self.case_url)
if len(self.metadata) > 100:
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
@runtime_checkable
class TicketCaseEscalationProvider(Protocol):
def escalate_ticket(
self,
session: object,
principal: object,
*,
command: TicketCaseEscalationCommand,
) -> TicketCaseEscalationResult: ...
def ticket_routing_provider(registry: object | None) -> TicketRoutingProvider | None:
provider = _capability(registry, CAPABILITY_TICKET_ROUTING)
return provider if isinstance(provider, TicketRoutingProvider) else None
def ticket_case_escalation_provider(
registry: object | None,
) -> TicketCaseEscalationProvider | None:
provider = _capability(registry, CAPABILITY_TICKET_CASE_ESCALATION)
return provider if isinstance(provider, TicketCaseEscalationProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _required(value: str, label: str, maximum: int) -> None:
if not value.strip() or len(value) > maximum:
raise ValueError(f"{label} must contain 1 to {maximum} characters.")
def _optional(value: str | None, label: str, maximum: int) -> None:
if value is not None and (not value.strip() or len(value) > maximum):
raise ValueError(f"{label} must contain 1 to {maximum} characters when set.")
def _aware(value: datetime | None, label: str) -> None:
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
raise ValueError(f"{label} must include a timezone.")
def _relative_url(value: str) -> None:
if (
not value.startswith("/")
or value.startswith("//")
or "\\" in value
or len(value) > 1_500
or any(ord(character) < 32 or ord(character) == 127 for character in value)
):
raise ValueError("Ticket escalation URLs must be bounded application-relative paths.")
__all__ = [
"CAPABILITY_TICKET_CASE_ESCALATION",
"CAPABILITY_TICKET_ROUTING",
"TICKET_INTEGRATION_CONTRACT_VERSION",
"TicketCaseEscalationCommand",
"TicketCaseEscalationProvider",
"TicketCaseEscalationResult",
"TicketRoutingPlan",
"TicketRoutingProvider",
"TicketRoutingRequest",
"ticket_case_escalation_provider",
"ticket_routing_provider",
]