feat(tickets): deliver canonical operational lifecycle
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
from govoplan_tickets.backend.db.models import (
|
||||
Ticket,
|
||||
TicketComment,
|
||||
TicketEscalation,
|
||||
TicketHistory,
|
||||
)
|
||||
|
||||
__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"]
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Ticket(Base, TimestampMixin):
|
||||
__tablename__ = "tickets"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "ticket_number", name="uq_ticket_number"),
|
||||
Index("ix_ticket_queue", "tenant_id", "queue_ref", "status", "priority"),
|
||||
Index("ix_ticket_catalog", "tenant_id", "status", "updated_at"),
|
||||
Index("ix_ticket_service_target", "tenant_id", "service_target_at", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(255), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
ticket_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
ticket_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
priority: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
queue_ref: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
assignee: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
reporter: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
requester: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
participants: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
|
||||
links: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
|
||||
metadata_payload: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, nullable=False, default=dict)
|
||||
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
|
||||
service_target_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
resolution_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class TicketHistory(Base, TimestampMixin):
|
||||
__tablename__ = "ticket_history"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "ticket_id", "revision", name="uq_ticket_history_revision"),
|
||||
UniqueConstraint("tenant_id", "idempotency_key", name="uq_ticket_history_idempotency"),
|
||||
Index("ix_ticket_history_timeline", "tenant_id", "ticket_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
ticket_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
|
||||
class TicketComment(Base, TimestampMixin):
|
||||
__tablename__ = "ticket_comments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "comment_id", name="uq_ticket_comment"),
|
||||
Index("ix_ticket_comment_timeline", "tenant_id", "ticket_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
ticket_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
comment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
ticket_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class TicketEscalation(Base, TimestampMixin):
|
||||
__tablename__ = "ticket_escalations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "ticket_id", "idempotency_key", name="uq_ticket_escalation_replay"),
|
||||
UniqueConstraint("tenant_id", "provider_id", "case_id", name="uq_ticket_case_link"),
|
||||
Index("ix_ticket_escalation_timeline", "tenant_id", "ticket_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
ticket_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("tickets.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
provider_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
case_number: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
case_url: Mapped[str] = mapped_column(String(1_500), nullable=False)
|
||||
handoff_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
outcome: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
|
||||
__all__ = ["Ticket", "TicketComment", "TicketEscalation", "TicketHistory"]
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
|
||||
TicketType = Literal["request", "incident", "problem", "report"]
|
||||
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
||||
TicketVisibility = Literal["tenant", "restricted"]
|
||||
|
||||
TICKET_TYPES = frozenset({"request", "incident", "problem", "report"})
|
||||
TICKET_PRIORITIES = frozenset({"low", "normal", "high", "urgent"})
|
||||
TICKET_VISIBILITIES = frozenset({"tenant", "restricted"})
|
||||
TICKET_STATES = frozenset(
|
||||
{
|
||||
"new",
|
||||
"triaged",
|
||||
"in_progress",
|
||||
"waiting",
|
||||
"resolved",
|
||||
"closed",
|
||||
"cancelled",
|
||||
}
|
||||
)
|
||||
TICKET_TRANSITIONS: Mapping[str, frozenset[str]] = {
|
||||
"new": frozenset({"triaged", "in_progress", "cancelled"}),
|
||||
"triaged": frozenset({"in_progress", "waiting", "resolved", "cancelled"}),
|
||||
"in_progress": frozenset({"waiting", "resolved", "cancelled"}),
|
||||
"waiting": frozenset({"in_progress", "resolved", "cancelled"}),
|
||||
"resolved": frozenset({"closed", "in_progress"}),
|
||||
"closed": frozenset({"in_progress"}),
|
||||
"cancelled": frozenset({"in_progress"}),
|
||||
}
|
||||
SUBJECT_KINDS = frozenset(
|
||||
{
|
||||
"account",
|
||||
"identity",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"organization_unit",
|
||||
"service_account",
|
||||
"external",
|
||||
}
|
||||
)
|
||||
LINK_KINDS = frozenset(
|
||||
{
|
||||
"related",
|
||||
"attachment",
|
||||
"case",
|
||||
"project",
|
||||
"wiki",
|
||||
"asset",
|
||||
"facility",
|
||||
"form",
|
||||
"file",
|
||||
"external",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TicketDomainError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketSubjectRef:
|
||||
kind: str
|
||||
id: str
|
||||
label: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in SUBJECT_KINDS:
|
||||
raise TicketDomainError(f"Unsupported ticket subject kind: {self.kind!r}.")
|
||||
_required(self.id, "Ticket subject identifier", 255)
|
||||
_optional(self.label, "Ticket subject label", 500)
|
||||
_optional(self.role, "Ticket subject role", 80)
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str, str | None]:
|
||||
return self.kind, self.id, self.role
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"kind": self.kind, "id": self.id, "label": self.label, "role": self.role}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "TicketSubjectRef":
|
||||
return cls(
|
||||
kind=_required(value.get("kind"), "Ticket subject kind", 40),
|
||||
id=_required(value.get("id"), "Ticket subject identifier", 255),
|
||||
label=_optional(value.get("label"), "Ticket subject label", 500),
|
||||
role=_optional(value.get("role"), "Ticket subject role", 80),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketLink:
|
||||
link_id: str
|
||||
kind: str
|
||||
owner_module: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
relation: str = "related"
|
||||
label: str | None = None
|
||||
url: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.link_id, "Ticket link identifier", 255)
|
||||
if self.kind not in LINK_KINDS:
|
||||
raise TicketDomainError(f"Unsupported ticket link kind: {self.kind!r}.")
|
||||
_required(self.owner_module, "Ticket link owner module", 100)
|
||||
_required(self.resource_type, "Ticket link resource type", 100)
|
||||
_required(self.resource_id, "Ticket link resource identifier", 255)
|
||||
_required(self.relation, "Ticket link relation", 80)
|
||||
_optional(self.label, "Ticket link label", 500)
|
||||
_optional(self.url, "Ticket link URL", 1_500)
|
||||
if self.url:
|
||||
_safe_url(self.url)
|
||||
if len(self.metadata) > 50:
|
||||
raise TicketDomainError("Ticket link metadata is limited to 50 entries.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"link_id": self.link_id,
|
||||
"kind": self.kind,
|
||||
"owner_module": self.owner_module,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"relation": self.relation,
|
||||
"label": self.label,
|
||||
"url": self.url,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "TicketLink":
|
||||
metadata = value.get("metadata") or {}
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise TicketDomainError("Ticket link metadata must be an object.")
|
||||
return cls(
|
||||
link_id=_required(value.get("link_id"), "Ticket link identifier", 255),
|
||||
kind=_required(value.get("kind"), "Ticket link kind", 40),
|
||||
owner_module=_required(value.get("owner_module"), "Ticket link owner module", 100),
|
||||
resource_type=_required(value.get("resource_type"), "Ticket link resource type", 100),
|
||||
resource_id=_required(value.get("resource_id"), "Ticket link resource identifier", 255),
|
||||
relation=str(value.get("relation") or "related"),
|
||||
label=_optional(value.get("label"), "Ticket link label", 500),
|
||||
url=_optional(value.get("url"), "Ticket link URL", 1_500),
|
||||
metadata=dict(metadata),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketRecord:
|
||||
tenant_id: str
|
||||
ticket_id: str
|
||||
ticket_number: str
|
||||
revision: int
|
||||
ticket_type: TicketType
|
||||
priority: TicketPriority
|
||||
status: str
|
||||
title: str
|
||||
description: str
|
||||
visibility: TicketVisibility
|
||||
received_at: datetime
|
||||
recorded_at: datetime
|
||||
change_reason: str
|
||||
queue_ref: str | None = None
|
||||
assignee: TicketSubjectRef | None = None
|
||||
reporter: TicketSubjectRef | None = None
|
||||
requester: TicketSubjectRef | None = None
|
||||
participants: tuple[TicketSubjectRef, ...] = ()
|
||||
links: tuple[TicketLink, ...] = ()
|
||||
service_target_at: datetime | None = None
|
||||
resolved_at: datetime | None = None
|
||||
resolution_summary: str | None = None
|
||||
deleted_at: datetime | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Ticket tenant", 255)
|
||||
_required(self.ticket_id, "Ticket identifier", 255)
|
||||
_required(self.ticket_number, "Ticket number", 255)
|
||||
if self.revision < 1:
|
||||
raise TicketDomainError("Ticket revisions start at one.")
|
||||
if self.ticket_type not in TICKET_TYPES:
|
||||
raise TicketDomainError(f"Unsupported ticket type: {self.ticket_type!r}.")
|
||||
if self.priority not in TICKET_PRIORITIES:
|
||||
raise TicketDomainError(f"Unsupported ticket priority: {self.priority!r}.")
|
||||
if self.status not in TICKET_STATES:
|
||||
raise TicketDomainError(f"Unsupported ticket state: {self.status!r}.")
|
||||
if self.visibility not in TICKET_VISIBILITIES:
|
||||
raise TicketDomainError(f"Unsupported ticket visibility: {self.visibility!r}.")
|
||||
_required(self.title, "Ticket title", 500)
|
||||
_required(self.description, "Ticket description", 40_000)
|
||||
_required(self.change_reason, "Ticket change reason", 1_000)
|
||||
_optional(self.queue_ref, "Ticket queue reference", 255)
|
||||
_optional(self.resolution_summary, "Ticket resolution summary", 20_000)
|
||||
for value, label in (
|
||||
(self.received_at, "Ticket received_at"),
|
||||
(self.recorded_at, "Ticket recorded_at"),
|
||||
(self.service_target_at, "Ticket service_target_at"),
|
||||
(self.resolved_at, "Ticket resolved_at"),
|
||||
(self.deleted_at, "Ticket deleted_at"),
|
||||
):
|
||||
_aware(value, label)
|
||||
if self.resolved_at is not None and self.resolved_at < self.received_at:
|
||||
raise TicketDomainError("Ticket resolved_at cannot precede received_at.")
|
||||
if self.deleted_at is not None and self.deleted_at < self.received_at:
|
||||
raise TicketDomainError("Ticket deleted_at cannot precede received_at.")
|
||||
if self.status in {"resolved", "closed"} and not self.resolution_summary:
|
||||
raise TicketDomainError("Resolved or closed tickets require a resolution summary.")
|
||||
if self.status not in {"resolved", "closed"} and self.resolved_at is not None:
|
||||
raise TicketDomainError("Only resolved or closed tickets carry resolved_at.")
|
||||
participant_keys = {item.key for item in self.participants}
|
||||
if len(participant_keys) != len(self.participants):
|
||||
raise TicketDomainError("Ticket participants must be unique.")
|
||||
link_ids = {item.link_id for item in self.links}
|
||||
if len(link_ids) != len(self.links):
|
||||
raise TicketDomainError("Ticket link identifiers must be unique.")
|
||||
if len(self.participants) > 100 or len(self.links) > 200:
|
||||
raise TicketDomainError("Ticket participants or links exceed their bounded limits.")
|
||||
if len(self.metadata) > 100:
|
||||
raise TicketDomainError("Ticket metadata is limited to 100 entries.")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"ticket_id": self.ticket_id,
|
||||
"ticket_number": self.ticket_number,
|
||||
"revision": self.revision,
|
||||
"ticket_type": self.ticket_type,
|
||||
"priority": self.priority,
|
||||
"status": self.status,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"visibility": self.visibility,
|
||||
"queue_ref": self.queue_ref,
|
||||
"assignee": self.assignee.to_dict() if self.assignee else None,
|
||||
"reporter": self.reporter.to_dict() if self.reporter else None,
|
||||
"requester": self.requester.to_dict() if self.requester else None,
|
||||
"participants": [item.to_dict() for item in self.participants],
|
||||
"links": [item.to_dict() for item in self.links],
|
||||
"service_target_at": _datetime_text(self.service_target_at),
|
||||
"received_at": self.received_at.isoformat(),
|
||||
"recorded_at": self.recorded_at.isoformat(),
|
||||
"resolved_at": _datetime_text(self.resolved_at),
|
||||
"resolution_summary": self.resolution_summary,
|
||||
"deleted_at": _datetime_text(self.deleted_at),
|
||||
"change_reason": self.change_reason,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "TicketRecord":
|
||||
metadata = value.get("metadata") or {}
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise TicketDomainError("Ticket metadata must be an object.")
|
||||
return cls(
|
||||
tenant_id=_required(value.get("tenant_id"), "Ticket tenant", 255),
|
||||
ticket_id=_required(value.get("ticket_id"), "Ticket identifier", 255),
|
||||
ticket_number=_required(value.get("ticket_number"), "Ticket number", 255),
|
||||
revision=int(value.get("revision") or 0),
|
||||
ticket_type=cast(TicketType, _required(value.get("ticket_type"), "Ticket type", 80)),
|
||||
priority=cast(TicketPriority, _required(value.get("priority"), "Ticket priority", 40)),
|
||||
status=_required(value.get("status"), "Ticket state", 40),
|
||||
title=_required(value.get("title"), "Ticket title", 500),
|
||||
description=_required(value.get("description"), "Ticket description", 40_000),
|
||||
visibility=cast(TicketVisibility, str(value.get("visibility") or "tenant")),
|
||||
queue_ref=_optional(value.get("queue_ref"), "Ticket queue reference", 255),
|
||||
assignee=_subject(value.get("assignee")),
|
||||
reporter=_subject(value.get("reporter")),
|
||||
requester=_subject(value.get("requester")),
|
||||
participants=tuple(
|
||||
TicketSubjectRef.from_mapping(item)
|
||||
for item in _mapping_items(value.get("participants"), "Ticket participants")
|
||||
),
|
||||
links=tuple(
|
||||
TicketLink.from_mapping(item)
|
||||
for item in _mapping_items(value.get("links"), "Ticket links")
|
||||
),
|
||||
service_target_at=_optional_datetime(value.get("service_target_at")),
|
||||
received_at=_datetime(value.get("received_at"), "Ticket received_at"),
|
||||
recorded_at=_datetime(value.get("recorded_at"), "Ticket recorded_at"),
|
||||
resolved_at=_optional_datetime(value.get("resolved_at")),
|
||||
resolution_summary=_optional(value.get("resolution_summary"), "Ticket resolution summary", 20_000),
|
||||
deleted_at=_optional_datetime(value.get("deleted_at")),
|
||||
change_reason=_required(value.get("change_reason"), "Ticket change reason", 1_000),
|
||||
metadata=dict(metadata),
|
||||
)
|
||||
|
||||
|
||||
def validate_transition(current: str, target: str) -> None:
|
||||
if current == target:
|
||||
return
|
||||
if current not in TICKET_TRANSITIONS or target not in TICKET_TRANSITIONS[current]:
|
||||
raise TicketDomainError(f"Ticket transition from {current!r} to {target!r} is not allowed.")
|
||||
|
||||
|
||||
def _subject(value: object) -> TicketSubjectRef | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise TicketDomainError("Ticket subject references must be objects.")
|
||||
return TicketSubjectRef.from_mapping(value)
|
||||
|
||||
|
||||
def _mapping_items(value: object, label: str) -> Sequence[Mapping[str, object]]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, (list, tuple)) or any(not isinstance(item, Mapping) for item in value):
|
||||
raise TicketDomainError(f"{label} must be a list of objects.")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
def _required(value: object, label: str, maximum: int) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > maximum:
|
||||
raise TicketDomainError(f"{label} must contain 1 to {maximum} characters.")
|
||||
return clean
|
||||
|
||||
|
||||
def _optional(value: object, label: str, maximum: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = str(value).strip()
|
||||
if not clean or len(clean) > maximum:
|
||||
raise TicketDomainError(f"{label} must contain 1 to {maximum} characters when set.")
|
||||
return clean
|
||||
|
||||
|
||||
def _aware(value: datetime | None, label: str) -> None:
|
||||
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||
raise TicketDomainError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _datetime(value: object, label: str) -> datetime:
|
||||
result = _optional_datetime(value)
|
||||
if result is None:
|
||||
raise TicketDomainError(f"{label} is required.")
|
||||
return result
|
||||
|
||||
|
||||
def _optional_datetime(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
result = value
|
||||
else:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise TicketDomainError("Ticket timestamp is invalid.") from exc
|
||||
_aware(result, "Ticket timestamp")
|
||||
return result
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _safe_url(value: str) -> None:
|
||||
if "\\" in value or any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise TicketDomainError("Ticket link URL contains unsafe characters.")
|
||||
if value.startswith("//"):
|
||||
raise TicketDomainError("Ticket link URL must not be scheme-relative.")
|
||||
if not (value.startswith("/") or value.startswith("https://") or value.startswith("http://")):
|
||||
raise TicketDomainError("Ticket link URL must be application-relative or HTTP(S).")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LINK_KINDS",
|
||||
"SUBJECT_KINDS",
|
||||
"TICKET_PRIORITIES",
|
||||
"TICKET_STATES",
|
||||
"TICKET_TRANSITIONS",
|
||||
"TICKET_TYPES",
|
||||
"TicketDomainError",
|
||||
"TicketLink",
|
||||
"TicketRecord",
|
||||
"TicketSubjectRef",
|
||||
"validate_transition",
|
||||
]
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_tickets.backend.db.models import Ticket, TicketComment, TicketHistory
|
||||
|
||||
|
||||
TICKETS_DSAR_CAPABILITY = dsar_capability_name("tickets")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Selectors:
|
||||
subject_ids: tuple[str, ...]
|
||||
ticket_id: str | None
|
||||
|
||||
|
||||
class TicketsDsarProvider:
|
||||
provider_id = "tickets"
|
||||
module_id = "tickets"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
query = db.query(Ticket).filter(Ticket.tenant_id == tenant_id)
|
||||
if selectors.ticket_id:
|
||||
query = query.filter(Ticket.id == selectors.ticket_id)
|
||||
rows = query.limit(_MAX_RECORDS + 1).all()
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError("Tickets DSAR result limit exceeded; narrow the selectors.")
|
||||
records: list[DsarRecordRef] = []
|
||||
for row in rows:
|
||||
roles = _subject_roles(row, selectors.subject_ids)
|
||||
activities = _activities(db, row, selectors.subject_ids)
|
||||
comments = _comments(db, row, selectors.subject_ids)
|
||||
if roles or comments:
|
||||
records.append(_participation_record(row, roles, comments, activities))
|
||||
elif activities:
|
||||
records.append(_actor_record(row, activities))
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Tickets DSAR subject selectors conflict.")
|
||||
actions = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
participation = record.resource_type == "ticket_participation"
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"tickets:{'manual_review' if participation else 'retain'}:{record.resource_type}:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="manual_review" if participation else "retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Review {record.title}" if participation else f"Retain {record.title}",
|
||||
rationale=(
|
||||
"Reporter, requester, participant, and comment references may be operational evidence. "
|
||||
"The ticket owner and applicable retention policy must decide whether they can be detached or minimized."
|
||||
if participation
|
||||
else record.retention_reason or "Ticket lifecycle attribution is immutable accountability evidence."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Tickets DSAR subject selectors conflict.")
|
||||
results = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind not in {"retain", "manual_review"}:
|
||||
raise ValueError("Tickets DSAR publishes non-executable actions only.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The ticket remains unchanged pending owner and retention review."
|
||||
if action.kind == "manual_review"
|
||||
else "Ticket lifecycle attribution remains immutable evidence."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||
refs = subject.external_references
|
||||
values = (
|
||||
_coalesce(subject.account_id, refs.get("tickets.account"), refs.get("access.account")),
|
||||
_coalesce(subject.identity_id, refs.get("tickets.identity"), refs.get("identity.id")),
|
||||
_coalesce(subject.membership_id, refs.get("tickets.membership"), refs.get("tenancy.membership")),
|
||||
)
|
||||
ticket = _coalesce(refs.get("tickets.ticket"), refs.get("tickets.item"))
|
||||
if any(value is _CONFLICT for value in (*values, ticket)):
|
||||
return None
|
||||
subject_ids = tuple(dict.fromkeys(value for value in values if isinstance(value, str) and value))
|
||||
if not subject_ids:
|
||||
return None
|
||||
return _Selectors(subject_ids=subject_ids, ticket_id=ticket if isinstance(ticket, str) else None)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _subject_roles(row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]:
|
||||
selected = set(subject_ids)
|
||||
roles: list[dict[str, object]] = []
|
||||
for role, value in (
|
||||
("reporter", row.reporter),
|
||||
("requester", row.requester),
|
||||
("assignee", row.assignee),
|
||||
):
|
||||
if isinstance(value, Mapping) and str(value.get("id") or "") in selected:
|
||||
roles.append({"role": role, "kind": value.get("kind"), "id": value.get("id"), "label": value.get("label")})
|
||||
for value in row.participants or ():
|
||||
if isinstance(value, Mapping) and str(value.get("id") or "") in selected:
|
||||
roles.append({"role": value.get("role") or "participant", "kind": value.get("kind"), "id": value.get("id"), "label": value.get("label")})
|
||||
return roles[:100]
|
||||
|
||||
|
||||
def _activities(session: Session, row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]:
|
||||
selected = set(subject_ids)
|
||||
history = (
|
||||
session.query(TicketHistory)
|
||||
.filter(
|
||||
TicketHistory.tenant_id == row.tenant_id,
|
||||
TicketHistory.ticket_id == row.id,
|
||||
TicketHistory.actor_id.in_(tuple(selected)),
|
||||
)
|
||||
.order_by(TicketHistory.revision.asc())
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"event_type": item.event_type, "revision": item.revision, "occurred_at": _iso(item.occurred_at)}
|
||||
for item in history
|
||||
]
|
||||
|
||||
|
||||
def _comments(session: Session, row: Ticket, subject_ids: Sequence[str]) -> list[dict[str, object]]:
|
||||
comments = (
|
||||
session.query(TicketComment)
|
||||
.filter(
|
||||
TicketComment.tenant_id == row.tenant_id,
|
||||
TicketComment.ticket_id == row.id,
|
||||
TicketComment.created_by.in_(tuple(subject_ids)),
|
||||
)
|
||||
.order_by(TicketComment.created_at.asc())
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"comment_id": item.comment_id,
|
||||
"body": item.body[:20_000],
|
||||
"visibility": item.visibility,
|
||||
"created_at": _iso(item.created_at),
|
||||
}
|
||||
for item in comments
|
||||
]
|
||||
|
||||
|
||||
def _participation_record(
|
||||
row: Ticket,
|
||||
roles: list[dict[str, object]],
|
||||
comments: list[dict[str, object]],
|
||||
activities: list[dict[str, object]],
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="tickets",
|
||||
module_id="tickets",
|
||||
resource_type="ticket_participation",
|
||||
resource_id=row.id,
|
||||
category="operational_service_request",
|
||||
title=f"Ticket participation: {row.ticket_number}",
|
||||
data={
|
||||
"ticket_number": row.ticket_number,
|
||||
"ticket_type": row.ticket_type,
|
||||
"priority": row.priority,
|
||||
"status": row.status,
|
||||
"title": row.title[:500],
|
||||
"description": row.description[:40_000],
|
||||
"subject_roles": roles,
|
||||
"subject_comments": comments,
|
||||
"subject_activities": activities,
|
||||
"received_at": _iso(row.received_at),
|
||||
"resolved_at": _iso(row.resolved_at),
|
||||
"deleted_at": _iso(row.deleted_at),
|
||||
"revision": row.revision,
|
||||
},
|
||||
observed_at=_aware(row.updated_at or row.recorded_at),
|
||||
retention_reason="The ticket may document institutional service delivery and requires owner review before subject references are changed.",
|
||||
)
|
||||
|
||||
|
||||
def _actor_record(row: Ticket, activities: list[dict[str, object]]) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="tickets",
|
||||
module_id="tickets",
|
||||
resource_type="ticket_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="operator_accountability_evidence",
|
||||
title="Ticket lifecycle attribution",
|
||||
data={"activities": activities, "status": row.status, "revision": row.revision},
|
||||
observed_at=_aware(row.updated_at or row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Ticket lifecycle attribution is immutable accountability evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
result = _aware(value)
|
||||
return result.isoformat() if result else None
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Tickets DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "tickets" or record.module_id != "tickets" or record.resource_type not in {"ticket_participation", "ticket_actor_attribution"}:
|
||||
raise ValueError("Tickets DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "tickets" or action.module_id != "tickets" or not action.action_id.startswith("tickets:"):
|
||||
raise ValueError("Tickets DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["TICKETS_DSAR_CAPABILITY", "TicketsDsarProvider"]
|
||||
@@ -1,17 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.tickets import (
|
||||
CAPABILITY_TICKET_CASE_ESCALATION,
|
||||
CAPABILITY_TICKET_ROUTING,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_tickets.backend.db import models as ticket_models
|
||||
from govoplan_tickets.backend.dsar_provider import (
|
||||
TICKETS_DSAR_CAPABILITY,
|
||||
TicketsDsarProvider,
|
||||
)
|
||||
from govoplan_tickets.backend.search_source import create_tickets_search_source
|
||||
from govoplan_tickets.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
ASSIGN_SCOPE,
|
||||
CAPABILITY_TICKETS_REGISTRY,
|
||||
LEGACY_WRITE_SCOPE,
|
||||
READ_SCOPE,
|
||||
REPORT_SCOPE,
|
||||
RESOLVE_SCOPE,
|
||||
TRIAGE_SCOPE,
|
||||
SqlTicketRegistry,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "tickets"
|
||||
MODULE_NAME = "Tickets"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "tickets:ticket:read"
|
||||
WRITE_SCOPE = "tickets:ticket:write"
|
||||
ADMIN_SCOPE = "tickets:ticket:admin"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
WRITE_SCOPE = LEGACY_WRITE_SCOPE
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"cases",
|
||||
"helpdesk",
|
||||
"projects",
|
||||
"wiki",
|
||||
"assets",
|
||||
@@ -33,7 +87,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Tickets",
|
||||
category=MODULE_NAME,
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
@@ -41,30 +95,64 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_tickets.backend.router import create_router
|
||||
|
||||
return create_router(context.registry)
|
||||
|
||||
|
||||
def _registry(context: ModuleContext) -> SqlTicketRegistry:
|
||||
return SqlTicketRegistry(context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> TicketsDsarProvider:
|
||||
return TicketsDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
counts = {
|
||||
status: int(count)
|
||||
for status, count in session.query(ticket_models.Ticket.status, func.count())
|
||||
.filter(
|
||||
ticket_models.Ticket.tenant_id == tenant_id,
|
||||
ticket_models.Ticket.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(ticket_models.Ticket.status)
|
||||
.all()
|
||||
}
|
||||
return {
|
||||
"tickets": sum(counts.values()),
|
||||
"open_tickets": sum(
|
||||
count
|
||||
for state, count in counts.items()
|
||||
if state not in {"resolved", "closed", "cancelled"}
|
||||
),
|
||||
"resolved_tickets": counts.get("resolved", 0) + counts.get("closed", 0),
|
||||
}
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View tickets",
|
||||
"Read discoverable tickets, queue state, and resolution context.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage tickets",
|
||||
"Create, triage, assign, update, resolve, and link tickets.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer tickets",
|
||||
"Configure ticket types, queues, service policies, and intake profiles.",
|
||||
),
|
||||
_permission(READ_SCOPE, "View tickets", "Read discoverable tickets, comments, links, queue state, and immutable history."),
|
||||
_permission(REPORT_SCOPE, "Report tickets", "Create operational reports and requests and add external follow-up comments."),
|
||||
_permission(TRIAGE_SCOPE, "Triage tickets", "Classify tickets, manage participants and links, and select queues and service targets."),
|
||||
_permission(ASSIGN_SCOPE, "Assign tickets", "Assign or reassign tickets to accounts, groups, roles, functions, or organization units."),
|
||||
_permission(RESOLVE_SCOPE, "Resolve tickets", "Advance, resolve, close, cancel, and reopen tickets with resolution evidence."),
|
||||
_permission(ADMIN_SCOPE, "Administer tickets", "Inspect all tenant tickets, configure ticket behavior, and soft-delete operational records."),
|
||||
_permission(LEGACY_WRITE_SCOPE, "Manage tickets (compatibility)", "Preserve existing broad manager grants while deployments migrate to the distinct triage, assignment, and resolution scopes."),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="tickets_reporter",
|
||||
name="Tickets reporter",
|
||||
description="Report and follow accessible operational tickets.",
|
||||
permissions=(READ_SCOPE, REPORT_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tickets_manager",
|
||||
name="Tickets manager",
|
||||
description="Triage, assign, update, and resolve tickets.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
description="Triage, assign, update, escalate, and resolve tickets.",
|
||||
permissions=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tickets_viewer",
|
||||
@@ -72,20 +160,23 @@ ROLE_TEMPLATES = (
|
||||
description="Read discoverable tickets and their resolution context.",
|
||||
permissions=(READ_SCOPE,),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tickets_administrator",
|
||||
name="Tickets administrator",
|
||||
description="Administer the complete tenant ticket lifecycle and recovery surface.",
|
||||
permissions=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary=(
|
||||
"Queue-oriented reports, requests, incidents, problems, triage, "
|
||||
"routing, service work, and auditable resolution."
|
||||
),
|
||||
id="tickets.module-boundary",
|
||||
title="Tickets module boundary",
|
||||
summary="Operational requests, incidents, problems, reports, queue work, service targets, and auditable resolution.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
"Tickets owns the operational service record and its lifecycle. Cases remains authoritative for formal procedures; "
|
||||
"Helpdesk may contribute routing and service-target policy without creating a second ticket store. Attachments and "
|
||||
"related work are typed references. Soft deletion removes a ticket from ordinary work and Search while retaining its immutable history."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -93,41 +184,122 @@ DOCUMENTATION = (
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Modulgrenze von Tickets",
|
||||
"summary": "Warteschlangenorientierte Meldungen, Anfragen, Störungen, Probleme, Triage, Weiterleitung, Servicearbeit und nachvollziehbare Lösungen.",
|
||||
"body": "Dieses Repository ist derzeit ein Grundgerüst für ein Plattformmodul. Es registriert die Fachgrenze, Berechtigungsoberfläche, Rollenvorlagen und Dokumentationsmetadaten, bevor Laufzeit-APIs, Datenbankmodelle, Migrationen und WebUI-Routen eingeführt werden.",
|
||||
"summary": "Operative Anfragen, Störungen, Probleme, Meldungen, Warteschlangenarbeit, Serviceziele und nachvollziehbare Lösungen.",
|
||||
"body": "Tickets verantwortet den operativen Servicevorgang und seinen Lebenszyklus. Cases bleibt für förmliche Verfahren maßgeblich; Helpdesk kann Weiterleitung und Serviceziele beisteuern, ohne einen zweiten Ticketspeicher anzulegen. Anhänge und verbundene Arbeit sind typisierte Verweise. Eine weiche Löschung entfernt ein Ticket aus der normalen Arbeit und Suche, erhält aber die unveränderliche Historie.",
|
||||
}
|
||||
},
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Repository domain boundary",
|
||||
href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
links=(DocumentationLink(label="Repository domain boundary", href="govoplan-tickets/docs/TICKETS_DOMAIN_BOUNDARY.md", kind="repository"),),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"seed": True,
|
||||
"consequence_classes": {
|
||||
"seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.",
|
||||
"canonical_store": "Tickets is the only operational ticket store; Helpdesk supplies policy and queue semantics.",
|
||||
"case_boundary": "Case escalation creates a stable auditable link and never converts or copies ticket history.",
|
||||
"soft_delete": "Deletion hides current work but preserves immutable accountability evidence.",
|
||||
},
|
||||
"domain_objects": [
|
||||
"ticket",
|
||||
"ticket type and queue",
|
||||
"triage and routing facts",
|
||||
"reporter and requester references",
|
||||
"assignment and service-level state",
|
||||
"resolution and escalation links",
|
||||
],
|
||||
"first_slice": (
|
||||
"Define ticket identity, intake profiles, queues, triage, "
|
||||
"assignment, resolution, and stable escalation links to cases."
|
||||
),
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="tickets.operational-workflow",
|
||||
title="Report, triage, assign, and resolve tickets",
|
||||
summary="Use guarded actions and revision evidence to move operational work from intake to a confirmed outcome.",
|
||||
body=(
|
||||
"Reporters create a request, incident, problem, or report. Triage staff classify it, select a queue and target, and manage typed participants and references. "
|
||||
"Assignment and resolution are separate permissions. Every accepted mutation requires the current revision, a reason, actor, time, and idempotency key. "
|
||||
"Resolved and closed tickets require a resolution summary; reopening clears the resolved timestamp and continues the same record."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "operator", "tenant_admin"),
|
||||
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE)),),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Tickets melden, sichten, zuweisen und lösen",
|
||||
"summary": "Operative Arbeit mit geschützten Aktionen und Revisionsnachweisen von der Aufnahme bis zum bestätigten Ergebnis führen.",
|
||||
"body": "Meldende erstellen eine Anfrage, Störung, ein Problem oder eine Meldung. Die Triage klassifiziert sie, wählt Warteschlange und Ziel und pflegt typisierte Beteiligte und Verweise. Zuweisung und Lösung sind getrennte Berechtigungen. Jede angenommene Änderung benötigt die aktuelle Revision, einen Grund, Akteur, Zeitpunkt und Idempotenzschlüssel. Gelöste und geschlossene Tickets benötigen eine Lösungszusammenfassung; eine Wiedereröffnung führt denselben Vorgang fort.",
|
||||
}
|
||||
},
|
||||
related_modules=("helpdesk", "cases", "files", "search"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"tickets.route.workspace",
|
||||
"tickets.action.report",
|
||||
"tickets.action.triage",
|
||||
"tickets.action.assign",
|
||||
"tickets.action.resolve",
|
||||
"tickets.action.escalate",
|
||||
"tickets.field.queue",
|
||||
"tickets.field.service-target",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="tickets.optional-integrations",
|
||||
title="Optional ticket integrations and reduced installations",
|
||||
summary="Understand what remains available when Cases, Helpdesk, Files, Projects, Wiki, or Search is absent.",
|
||||
body=(
|
||||
"Without Helpdesk, authorized users select queues and service targets manually. Without Cases, formal escalation is disabled but ticket resolution remains available. "
|
||||
"Without Files, attachments remain external references and Tickets never stores bytes. Project and Wiki references are retained without owner validation when those modules are absent. "
|
||||
"Without Search, the ticket workspace and API remain usable but global discovery and indexing are unavailable. The workspace reports these consequences explicitly."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "tenant_admin"),
|
||||
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Optionale Ticket-Integrationen und reduzierte Installationen",
|
||||
"summary": "Verstehen, was ohne Cases, Helpdesk, Files, Projects, Wiki oder Search verfügbar bleibt.",
|
||||
"body": "Ohne Helpdesk wählen Berechtigte Warteschlange und Serviceziel manuell. Ohne Cases ist die förmliche Eskalation deaktiviert, die Ticketlösung bleibt verfügbar. Ohne Files bleiben Anhänge externe Verweise; Tickets speichert keine Dateiinhalte. Projekt- und Wiki-Verweise bleiben ohne Prüfung durch das Eigentümermodul erhalten. Ohne Search bleiben Arbeitsbereich und API nutzbar, globale Suche und Indizierung fehlen. Der Arbeitsbereich weist auf diese Folgen hin.",
|
||||
}
|
||||
},
|
||||
metadata={"kind": "workflow", "help_contexts": ["tickets.page.availability"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="tickets.data-subject-requests",
|
||||
title="Ticket data-subject requests",
|
||||
summary="Export exact ticket participation and minimized actor attribution without automatically changing operational evidence.",
|
||||
body=(
|
||||
"Tickets matches exact account, identity, and membership identifiers inside the active tenant. Reporter, requester, assignee, participant, and authored-comment records include bounded ticket context. "
|
||||
"Actor-only matches expose minimized lifecycle attribution. Arbitrary metadata, internal comments by other actors, request hashes, and idempotency keys are excluded. "
|
||||
"Erasure remains a manual owner and retention review because ticket content and attribution can be institutional accountability evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "privacy_officer", "auditor", "user"),
|
||||
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Datenschutzanfragen für Tickets",
|
||||
"summary": "Exakte Ticket-Beteiligung und minimierte Akteurszuordnung exportieren, ohne operative Nachweise automatisch zu verändern.",
|
||||
"body": "Tickets gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen innerhalb des aktiven Mandanten ab. Treffer als meldende, anfragende, zugewiesene oder beteiligte Person sowie eigene Kommentare enthalten begrenzten Ticketkontext. Reine Akteurstreffer liefern eine minimierte Lebenszykluszuordnung. Beliebige Metadaten, interne Kommentare anderer Akteure, Anfrage-Hashes und Idempotenzschlüssel werden ausgeschlossen. Eine Löschung bleibt eine manuelle Prüfung durch Eigentümer und Aufbewahrungsverantwortliche, weil Inhalt und Zuordnung institutionelle Rechenschaftsnachweise sein können.",
|
||||
}
|
||||
},
|
||||
metadata={"kind": "workflow", "help_contexts": ["tickets.admin.dsar"]},
|
||||
),
|
||||
)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="human_work_procedure",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(kind="documentation", reference="docs/TICKETS_DOMAIN_BOUNDARY.md", summary="Defines ticket, Case, and Helpdesk authority boundaries."),
|
||||
ModuleMaturityEvidence(kind="test", reference="tests/test_ticket_service.py", summary="Proves lifecycle, replay safety, tenant isolation, access, integrations, history, and Search changes."),
|
||||
),
|
||||
known_limits=(
|
||||
"Helpdesk-owned configurable queue and service-profile administration is a separate provider slice.",
|
||||
"External service-desk transport and synchronization remain connector-owned work.",
|
||||
"Attachments are file references; binary storage remains Files-owned.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative", "linked_reference", "governed_sync"),
|
||||
owned_concepts=("ticket", "ticket queue reference", "ticket lifecycle", "ticket resolution", "ticket escalation link"),
|
||||
non_owned_concepts=("formal case", "helpdesk service profile", "file content", "external service-desk transport"),
|
||||
documentation=ModuleArchitectureDocumentation(operations=("docs/TICKETS_DOMAIN_BOUNDARY.md",)),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
@@ -135,18 +307,70 @@ manifest = ModuleManifest(
|
||||
dependencies=("access",),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_capabilities=(CAPABILITY_TICKET_ROUTING, CAPABILITY_TICKET_CASE_ESCALATION),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_TICKETS_REGISTRY, version="1.0.0"),
|
||||
ModuleInterfaceProvider(name=TICKETS_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
documentation=DOCUMENTATION,
|
||||
architecture=declared_module_architecture(
|
||||
layer="human_work_procedure",
|
||||
kind="domain",
|
||||
maturity="scaffold",
|
||||
documentation_ref="docs/TICKETS_DOMAIN_BOUNDARY.md",
|
||||
known_limits=("Ticket persistence, queues, SLA, and external service-desk adapters are not implemented yet.",),
|
||||
owned_concepts=("ticket", "ticket queue", "ticket transition"),
|
||||
non_owned_concepts=("case", "project", "external service-desk record"),
|
||||
route_factory=_router,
|
||||
nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/tickets-webui",
|
||||
routes=(FrontendRoute(path="/tickets", component="TicketsPage", required_any=(READ_SCOPE,), order=22, surface_id="tickets.route.workspace"),),
|
||||
nav_items=(NavItem(path="/tickets", label="Tickets", icon="ticket-check", required_any=(READ_SCOPE,), order=22, surface_id="tickets.navigation"),),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="work",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.work",
|
||||
icon="list-checks",
|
||||
description="i18n:govoplan-core.product_area.work_description",
|
||||
surface_ids=("tickets.route.workspace",),
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(id="tickets.page.queue", module_id=MODULE_ID, kind="section", label="Ticket queue", parent_id="tickets.route.workspace", order=30),
|
||||
ViewSurface(id="tickets.page.detail", module_id=MODULE_ID, kind="section", label="Ticket details", parent_id="tickets.route.workspace", order=40),
|
||||
ViewSurface(id="tickets.action.report", module_id=MODULE_ID, kind="action", label="Report ticket", parent_id="tickets.page.queue", order=50),
|
||||
ViewSurface(id="tickets.action.resolve", module_id=MODULE_ID, kind="action", label="Resolve ticket", parent_id="tickets.page.detail", order=60),
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
capability_factories={CAPABILITY_TICKETS_REGISTRY: _registry, TICKETS_DSAR_CAPABILITY: _dsar_provider},
|
||||
capability_documentation={
|
||||
CAPABILITY_TICKETS_REGISTRY: CapabilityDocumentation(label="Tickets registry", summary="Creates and reads replay-safe tenant ticket records without importing the Tickets implementation.", contract_version="1.0.0"),
|
||||
TICKETS_DSAR_CAPABILITY: CapabilityDocumentation(label="Tickets data-subject request provider", summary="Exports bounded ticket participation and minimized immutable actor attribution.", contract_version="0.1.0"),
|
||||
},
|
||||
search_sources=(SearchSourceProviderRegistration(id="tickets.tickets", factory=create_tickets_search_source),),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
ticket_models.TicketEscalation,
|
||||
ticket_models.TicketComment,
|
||||
ticket_models.TicketHistory,
|
||||
ticket_models.Ticket,
|
||||
label="Tickets",
|
||||
),
|
||||
retirement_notes="Destructive retirement removes ticket state and evidence only after an explicit database snapshot and retention review.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
ticket_models.Ticket,
|
||||
ticket_models.TicketHistory,
|
||||
ticket_models.TicketComment,
|
||||
ticket_models.TicketEscalation,
|
||||
label="Tickets",
|
||||
),
|
||||
),
|
||||
documentation=DOCUMENTATION,
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tickets-owned database migrations."""
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"""v0.1.20 canonical Tickets vertical slice.
|
||||
|
||||
Revision ID: 8d1f4b7a2c5e
|
||||
Revises: None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "8d1f4b7a2c5e"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tickets",
|
||||
sa.Column("id", sa.String(length=255), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("ticket_number", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("ticket_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("priority", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("visibility", sa.String(length=40), nullable=False),
|
||||
sa.Column("queue_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("assignee", sa.JSON(), nullable=True),
|
||||
sa.Column("reporter", sa.JSON(), nullable=True),
|
||||
sa.Column("requester", sa.JSON(), nullable=True),
|
||||
sa.Column("participants", sa.JSON(), nullable=False),
|
||||
sa.Column("links", sa.JSON(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("search_text", sa.Text(), nullable=False),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("change_reason", sa.String(length=1000), nullable=False),
|
||||
sa.Column("service_target_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolution_summary", sa.Text(), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_tickets")),
|
||||
sa.UniqueConstraint("tenant_id", "ticket_number", name="uq_ticket_number"),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id", "ticket_number", "ticket_type", "priority", "status", "visibility",
|
||||
"queue_ref", "received_at", "recorded_at", "service_target_at", "resolved_at",
|
||||
"deleted_at", "created_by", "updated_by",
|
||||
):
|
||||
op.create_index(op.f(f"ix_tickets_{column}"), "tickets", [column], unique=False)
|
||||
op.create_index("ix_ticket_queue", "tickets", ["tenant_id", "queue_ref", "status", "priority"], unique=False)
|
||||
op.create_index("ix_ticket_catalog", "tickets", ["tenant_id", "status", "updated_at"], unique=False)
|
||||
op.create_index("ix_ticket_service_target", "tickets", ["tenant_id", "service_target_at", "status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ticket_history",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("ticket_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("reason", sa.String(length=1000), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_history_ticket_id_tickets"), ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_history")),
|
||||
sa.UniqueConstraint("tenant_id", "ticket_id", "revision", name="uq_ticket_history_revision"),
|
||||
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_ticket_history_idempotency"),
|
||||
)
|
||||
for column in ("tenant_id", "ticket_id", "event_type", "occurred_at", "actor_id"):
|
||||
op.create_index(op.f(f"ix_ticket_history_{column}"), "ticket_history", [column], unique=False)
|
||||
op.create_index("ix_ticket_history_timeline", "ticket_history", ["tenant_id", "ticket_id", "occurred_at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ticket_comments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("ticket_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("comment_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("ticket_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("visibility", sa.String(length=40), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_comments_ticket_id_tickets"), ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_comments")),
|
||||
sa.UniqueConstraint("tenant_id", "comment_id", name="uq_ticket_comment"),
|
||||
)
|
||||
for column in ("tenant_id", "ticket_id", "comment_id", "visibility", "created_by"):
|
||||
op.create_index(op.f(f"ix_ticket_comments_{column}"), "ticket_comments", [column], unique=False)
|
||||
op.create_index("ix_ticket_comment_timeline", "ticket_comments", ["tenant_id", "ticket_id", "created_at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ticket_escalations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("ticket_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("provider_id", sa.String(length=200), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("case_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("case_number", sa.String(length=255), nullable=False),
|
||||
sa.Column("case_url", sa.String(length=1500), nullable=False),
|
||||
sa.Column("handoff_note", sa.Text(), nullable=True),
|
||||
sa.Column("outcome", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["ticket_id"], ["tickets.id"], name=op.f("fk_ticket_escalations_ticket_id_tickets"), ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_ticket_escalations")),
|
||||
sa.UniqueConstraint("tenant_id", "ticket_id", "idempotency_key", name="uq_ticket_escalation_replay"),
|
||||
sa.UniqueConstraint("tenant_id", "provider_id", "case_id", name="uq_ticket_case_link"),
|
||||
)
|
||||
for column in ("tenant_id", "ticket_id", "provider_id", "occurred_at", "actor_id", "case_id"):
|
||||
op.create_index(op.f(f"ix_ticket_escalations_{column}"), "ticket_escalations", [column], unique=False)
|
||||
op.create_index("ix_ticket_escalation_timeline", "ticket_escalations", ["tenant_id", "ticket_id", "occurred_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("ticket_escalations")
|
||||
op.drop_table("ticket_comments")
|
||||
op.drop_table("ticket_history")
|
||||
op.drop_table("tickets")
|
||||
@@ -0,0 +1 @@
|
||||
"""Tickets migration revisions."""
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_tickets.backend.domain import (
|
||||
TicketDomainError,
|
||||
TicketLink,
|
||||
TicketRecord,
|
||||
TicketSubjectRef,
|
||||
)
|
||||
from govoplan_tickets.backend.schemas import (
|
||||
TicketAssignmentRequest,
|
||||
TicketCommentMutationResponse,
|
||||
TicketCommentRequest,
|
||||
TicketCommentsResponse,
|
||||
TicketCreateRequest,
|
||||
TicketDeleteRequest,
|
||||
TicketEscalationRequest,
|
||||
TicketEscalationResponse,
|
||||
TicketHistoryResponse,
|
||||
TicketLinkRequest,
|
||||
TicketListResponse,
|
||||
TicketParticipantsRequest,
|
||||
TicketResolutionRequest,
|
||||
TicketTriageRequest,
|
||||
)
|
||||
from govoplan_tickets.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
LEGACY_WRITE_SCOPE,
|
||||
READ_SCOPE,
|
||||
REPORT_SCOPE,
|
||||
TicketConflictError,
|
||||
TicketIntegrationUnavailableError,
|
||||
TicketNotFoundError,
|
||||
TicketStoreError,
|
||||
add_ticket_comment,
|
||||
add_ticket_link,
|
||||
assign_ticket,
|
||||
create_ticket,
|
||||
delete_ticket,
|
||||
escalate_ticket_to_case,
|
||||
get_ticket,
|
||||
integration_availability,
|
||||
list_ticket_comments,
|
||||
list_tickets,
|
||||
remove_ticket_link,
|
||||
replace_participants,
|
||||
resolve_ticket,
|
||||
ticket_history,
|
||||
triage_ticket,
|
||||
)
|
||||
|
||||
|
||||
def create_router(registry: object | None) -> APIRouter:
|
||||
router = APIRouter(prefix="/tickets", tags=["tickets"])
|
||||
|
||||
@router.get("/availability", response_model=dict)
|
||||
def api_availability(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require(principal, READ_SCOPE)
|
||||
return integration_availability(registry)
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
def api_list_tickets(
|
||||
ticket_status: list[str] | None = Query(default=None, alias="status"),
|
||||
priority: list[str] | None = Query(default=None),
|
||||
ticket_type: list[str] | None = Query(default=None),
|
||||
queue_ref: str | None = Query(default=None, max_length=255),
|
||||
query: str = Query(default="", max_length=500),
|
||||
include_deleted: bool = False,
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TicketListResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
if include_deleted:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
try:
|
||||
items, total = list_tickets(
|
||||
session,
|
||||
principal,
|
||||
statuses=ticket_status or (),
|
||||
priorities=priority or (),
|
||||
ticket_types=ticket_type or (),
|
||||
queue_ref=queue_ref,
|
||||
query=query,
|
||||
include_deleted=include_deleted,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except (TicketStoreError, TicketDomainError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return TicketListResponse(
|
||||
tickets=[item.to_dict() for item in items],
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@router.post("", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
def api_create_ticket(
|
||||
payload: TicketCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require_any(principal, REPORT_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE)
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: create_ticket(
|
||||
session,
|
||||
principal,
|
||||
record=TicketRecord.from_mapping(payload.record),
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.get("/{ticket_id}", response_model=dict)
|
||||
def api_get_ticket(
|
||||
ticket_id: str,
|
||||
response: Response,
|
||||
include_deleted: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require(principal, READ_SCOPE)
|
||||
if include_deleted:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
item = get_ticket(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
_etag(response, item)
|
||||
return item.to_dict()
|
||||
|
||||
@router.patch("/{ticket_id}/triage", response_model=dict)
|
||||
def api_triage_ticket(
|
||||
ticket_id: str,
|
||||
payload: TicketTriageRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: triage_ticket(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
changes=payload.changes,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/{ticket_id}/assignment", response_model=dict)
|
||||
def api_assign_ticket(
|
||||
ticket_id: str,
|
||||
payload: TicketAssignmentRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: assign_ticket(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
assignee=(TicketSubjectRef.from_mapping(payload.assignee) if payload.assignee else None),
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/{ticket_id}/resolution", response_model=dict)
|
||||
def api_resolve_ticket(
|
||||
ticket_id: str,
|
||||
payload: TicketResolutionRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: resolve_ticket(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
target_status=payload.target_status,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
resolution_summary=payload.resolution_summary,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.put("/{ticket_id}/participants", response_model=dict)
|
||||
def api_replace_participants(
|
||||
ticket_id: str,
|
||||
payload: TicketParticipantsRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: replace_participants(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
participants=tuple(TicketSubjectRef.from_mapping(item) for item in payload.participants),
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/{ticket_id}/links", response_model=dict)
|
||||
def api_add_link(
|
||||
ticket_id: str,
|
||||
payload: TicketLinkRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: add_ticket_link(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
link=TicketLink.from_mapping(payload.link),
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.delete("/{ticket_id}/links/{link_id}", response_model=dict)
|
||||
def api_remove_link(
|
||||
ticket_id: str,
|
||||
link_id: str,
|
||||
payload: TicketTriageRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
if payload.changes:
|
||||
raise HTTPException(status_code=400, detail="Link removal changes must be empty")
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: remove_ticket_link(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
link_id=link_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
@router.get("/{ticket_id}/comments", response_model=TicketCommentsResponse)
|
||||
def api_ticket_comments(
|
||||
ticket_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TicketCommentsResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
items = list_ticket_comments(session, principal, ticket_id=ticket_id, limit=limit)
|
||||
except (TicketStoreError, TicketNotFoundError, PermissionError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return TicketCommentsResponse(comments=list(items))
|
||||
|
||||
@router.post("/{ticket_id}/comments", response_model=TicketCommentMutationResponse)
|
||||
def api_add_comment(
|
||||
ticket_id: str,
|
||||
payload: TicketCommentRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TicketCommentMutationResponse:
|
||||
try:
|
||||
item, comment = add_ticket_comment(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
comment_id=payload.comment_id,
|
||||
body=payload.body,
|
||||
visibility=payload.visibility,
|
||||
recorded_at=payload.recorded_at,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
)
|
||||
session.commit()
|
||||
except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
_etag(response, item)
|
||||
return TicketCommentMutationResponse(ticket=item.to_dict(), comment=comment)
|
||||
|
||||
@router.get("/{ticket_id}/history", response_model=TicketHistoryResponse)
|
||||
def api_ticket_history(
|
||||
ticket_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TicketHistoryResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
items = ticket_history(session, principal, ticket_id=ticket_id, limit=limit)
|
||||
except (TicketStoreError, TicketNotFoundError, PermissionError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return TicketHistoryResponse(history=list(items))
|
||||
|
||||
@router.post("/{ticket_id}/case-escalations", response_model=TicketEscalationResponse)
|
||||
def api_escalate_ticket(
|
||||
ticket_id: str,
|
||||
payload: TicketEscalationRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TicketEscalationResponse:
|
||||
try:
|
||||
item, escalation = escalate_ticket_to_case(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
case_type_key=payload.case_type_key,
|
||||
occurred_at=payload.occurred_at,
|
||||
handoff_note=payload.handoff_note,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
)
|
||||
session.commit()
|
||||
except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
_etag(response, item)
|
||||
return TicketEscalationResponse(ticket=item.to_dict(), escalation=escalation)
|
||||
|
||||
@router.delete("/{ticket_id}", response_model=dict)
|
||||
def api_delete_ticket(
|
||||
ticket_id: str,
|
||||
payload: TicketDeleteRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
return _write_ticket(
|
||||
session,
|
||||
response,
|
||||
lambda: delete_ticket(
|
||||
session,
|
||||
principal,
|
||||
ticket_id=ticket_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
occurred_at=payload.occurred_at,
|
||||
reason=payload.reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _write_ticket(session: Session, response: Response, operation) -> dict:
|
||||
try:
|
||||
item = operation()
|
||||
session.commit()
|
||||
except (TicketStoreError, TicketDomainError, TicketNotFoundError, PermissionError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
_etag(response, item)
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
def _etag(response: Response, item: TicketRecord) -> None:
|
||||
response.headers["ETag"] = strong_resource_etag("ticket", item.ticket_id, item.revision)
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(has_scope(principal, scope) for scope in scopes):
|
||||
raise HTTPException(status_code=403, detail=f"Requires one of: {', '.join(scopes)}")
|
||||
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, TicketIntegrationUnavailableError):
|
||||
code = 503
|
||||
elif isinstance(exc, TicketNotFoundError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
elif isinstance(exc, (TicketConflictError, IntegrityError)) or "conflict" in str(exc).casefold():
|
||||
code = 409
|
||||
else:
|
||||
code = 400
|
||||
return HTTPException(status_code=code, detail=str(exc))
|
||||
|
||||
|
||||
__all__ = ["create_router"]
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TicketCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
record: dict[str, Any]
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TicketMutationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TicketTriageRequest(TicketMutationRequest):
|
||||
changes: dict[str, Any]
|
||||
|
||||
|
||||
class TicketAssignmentRequest(TicketMutationRequest):
|
||||
assignee: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TicketResolutionRequest(TicketMutationRequest):
|
||||
target_status: str = Field(min_length=1, max_length=40)
|
||||
resolution_summary: str | None = Field(default=None, max_length=20_000)
|
||||
|
||||
|
||||
class TicketParticipantsRequest(TicketMutationRequest):
|
||||
participants: list[dict[str, Any]] = Field(max_length=100)
|
||||
|
||||
|
||||
class TicketLinkRequest(TicketMutationRequest):
|
||||
link: dict[str, Any]
|
||||
|
||||
|
||||
class TicketCommentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_revision: int = Field(ge=1)
|
||||
comment_id: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(min_length=1, max_length=20_000)
|
||||
visibility: str = Field(default="internal", max_length=40)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TicketEscalationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_revision: int = Field(ge=1)
|
||||
case_type_key: str = Field(min_length=1, max_length=120)
|
||||
occurred_at: datetime
|
||||
handoff_note: str | None = Field(default=None, max_length=10_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TicketDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_revision: int = Field(ge=1)
|
||||
occurred_at: datetime
|
||||
reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
tickets: list[dict[str, Any]]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
class TicketCommentsResponse(BaseModel):
|
||||
comments: list[dict[str, Any]]
|
||||
|
||||
|
||||
class TicketHistoryResponse(BaseModel):
|
||||
history: list[dict[str, Any]]
|
||||
|
||||
|
||||
class TicketCommentMutationResponse(BaseModel):
|
||||
ticket: dict[str, Any]
|
||||
comment: dict[str, Any]
|
||||
|
||||
|
||||
class TicketEscalationResponse(BaseModel):
|
||||
ticket: dict[str, Any]
|
||||
escalation: dict[str, Any]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TicketAssignmentRequest",
|
||||
"TicketCommentMutationResponse",
|
||||
"TicketCommentRequest",
|
||||
"TicketCommentsResponse",
|
||||
"TicketCreateRequest",
|
||||
"TicketDeleteRequest",
|
||||
"TicketEscalationRequest",
|
||||
"TicketEscalationResponse",
|
||||
"TicketHistoryResponse",
|
||||
"TicketLinkRequest",
|
||||
"TicketListResponse",
|
||||
"TicketParticipantsRequest",
|
||||
"TicketResolutionRequest",
|
||||
"TicketTriageRequest",
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_tickets.backend.db.models import Ticket
|
||||
from govoplan_tickets.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
ASSIGN_SCOPE,
|
||||
READ_SCOPE,
|
||||
RESOLVE_SCOPE,
|
||||
TRIAGE_SCOPE,
|
||||
can_read_ticket,
|
||||
)
|
||||
|
||||
|
||||
PROVIDER_ID = "tickets.tickets"
|
||||
RESOURCE_TYPE = "ticket"
|
||||
|
||||
|
||||
class TicketsSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="tickets",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Tickets",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(self, session: object, *, request: SearchBackfillRequest) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
query = select(Ticket).where(
|
||||
Ticket.tenant_id == request.tenant_id,
|
||||
Ticket.deleted_at.is_(None),
|
||||
)
|
||||
if request.cursor:
|
||||
query = query.where(Ticket.id > request.cursor)
|
||||
rows = tuple(db.scalars(query.order_by(Ticket.id.asc()).limit(request.limit + 1)))
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(Ticket.updated_at)).where(
|
||||
Ticket.tenant_id == request.tenant_id,
|
||||
Ticket.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(_document(row) for row in selected),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
db = _session(session)
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
if (
|
||||
reference.tenant_id != tenant_id
|
||||
or reference.module_id != "tickets"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = can_read_ticket(
|
||||
db,
|
||||
principal,
|
||||
ticket_id=reference.resource_id,
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "tickets"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type != RESOURCE_TYPE
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
row = db.scalar(
|
||||
select(Ticket).where(
|
||||
Ticket.tenant_id == event.tenant.id,
|
||||
Ticket.id == event.resource.id,
|
||||
)
|
||||
)
|
||||
deleted = row is None or row.deleted_at is not None
|
||||
cursor = event.event_id
|
||||
document = None if deleted else _document(row, change_cursor=cursor)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="tickets",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="delete" if deleted else "upsert",
|
||||
reference=reference,
|
||||
source_revision=document.source_revision if document else cursor,
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_tickets_search_source(_context: ModuleContext) -> TicketsSearchSource:
|
||||
return TicketsSearchSource()
|
||||
|
||||
|
||||
def _document(row: Ticket, *, change_cursor: str | None = None) -> SearchDocument:
|
||||
tokens = [
|
||||
f"scope:{READ_SCOPE}",
|
||||
f"scope:{TRIAGE_SCOPE}",
|
||||
f"scope:{ASSIGN_SCOPE}",
|
||||
f"scope:{RESOLVE_SCOPE}",
|
||||
f"scope:{ADMIN_SCOPE}",
|
||||
]
|
||||
if row.created_by:
|
||||
tokens.append(f"account:{row.created_by}")
|
||||
for value in (row.assignee, row.reporter, row.requester, *(row.participants or [])):
|
||||
if not isinstance(value, Mapping):
|
||||
continue
|
||||
kind = str(value.get("kind") or "")
|
||||
subject_id = str(value.get("id") or "")
|
||||
prefix = "function" if kind == "function_assignment" else kind
|
||||
if prefix and subject_id and prefix != "external":
|
||||
tokens.append(f"{prefix}:{subject_id}")
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="tickets",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.id,
|
||||
title=row.title,
|
||||
url=f"/tickets?ticketId={quote(row.id, safe='')}",
|
||||
summary=f"{row.ticket_number} · {row.status} · {row.priority}",
|
||||
body=row.search_text[:200_000],
|
||||
keywords=tuple(
|
||||
item[:200]
|
||||
for item in (row.ticket_number, row.ticket_type, row.status, row.priority, row.queue_ref or "")
|
||||
if item
|
||||
),
|
||||
visibility=row.visibility,
|
||||
acl_tokens=tuple(dict.fromkeys(tokens)) if row.visibility == "restricted" else (),
|
||||
metadata={
|
||||
"ticket_number": row.ticket_number,
|
||||
"ticket_type": row.ticket_type,
|
||||
"status": row.status,
|
||||
"priority": row.priority,
|
||||
"queue_ref": row.queue_ref,
|
||||
"service_target_at": row.service_target_at.isoformat() if row.service_target_at else None,
|
||||
},
|
||||
source_revision=str(row.revision),
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=row.updated_at or row.recorded_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Tickets search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Tickets search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"TicketsSearchSource",
|
||||
"create_tickets_search_source",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user