feat(tickets): deliver canonical operational lifecycle
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user