162 lines
5.4 KiB
Python
162 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from urllib.parse import quote
|
|
import uuid
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.institutional import (
|
|
GovernedContextEnvelope,
|
|
InstitutionalReference,
|
|
TemporalRevision,
|
|
)
|
|
from govoplan_core.core.tickets import (
|
|
TicketCaseEscalationCommand,
|
|
TicketCaseEscalationResult,
|
|
)
|
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
|
from govoplan_cases.backend.domain import CaseRecord
|
|
from govoplan_cases.backend.service import create_case, get_case, list_case_catalog
|
|
|
|
|
|
_CASE_NAMESPACE = uuid.uuid5(
|
|
uuid.NAMESPACE_URL,
|
|
"https://govoplan.add-ideas.de/contracts/tickets/case-escalation/v1",
|
|
)
|
|
|
|
|
|
class TicketCaseEscalationProvider:
|
|
"""Create one replay-safe formal Case while keeping Ticket history separate."""
|
|
|
|
def escalate_ticket(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
command: TicketCaseEscalationCommand,
|
|
) -> TicketCaseEscalationResult:
|
|
db = _session(session)
|
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
|
if command.tenant_id != tenant_id:
|
|
raise PermissionError("Ticket escalation cannot cross tenants.")
|
|
if not _has_scope(principal, "cases:case:create"):
|
|
raise PermissionError("Ticket escalation requires cases:case:create.")
|
|
case_id = str(
|
|
uuid.uuid5(
|
|
_CASE_NAMESPACE,
|
|
"\0".join((tenant_id, command.ticket_id, command.idempotency_key)),
|
|
)
|
|
)
|
|
existing = get_case(db, principal, case_id=case_id)
|
|
if existing is not None:
|
|
return _result(existing, replayed=True)
|
|
|
|
catalog = list_case_catalog(db, principal)
|
|
case_type = next(
|
|
(
|
|
item
|
|
for item in catalog["types"]
|
|
if item.get("type_key") == command.case_type_key and item.get("active")
|
|
),
|
|
None,
|
|
)
|
|
if case_type is None:
|
|
raise ValueError(
|
|
f"Case type {command.case_type_key!r} is not configured or active."
|
|
)
|
|
case_ref = InstitutionalReference(
|
|
kind="case",
|
|
owner_module="cases",
|
|
object_id=case_id,
|
|
tenant_id=tenant_id,
|
|
version="1",
|
|
valid_at=command.occurred_at,
|
|
)
|
|
ticket_ref = InstitutionalReference(
|
|
kind="work_item",
|
|
owner_module="tickets",
|
|
object_id=command.ticket_id,
|
|
tenant_id=tenant_id,
|
|
version=str(command.metadata.get("ticket_revision") or "1"),
|
|
valid_at=command.occurred_at,
|
|
label=command.ticket_number,
|
|
)
|
|
change_reason = f"Escalated from ticket {command.ticket_number}."
|
|
context = GovernedContextEnvelope(
|
|
tenant_id=tenant_id,
|
|
temporal=TemporalRevision(
|
|
revision="1",
|
|
valid_from=command.occurred_at,
|
|
recorded_at=command.occurred_at,
|
|
change_reason=change_reason,
|
|
),
|
|
case_ref=case_ref,
|
|
work_item_ref=ticket_ref,
|
|
)
|
|
record = CaseRecord(
|
|
reference=case_ref,
|
|
case_number=_case_number(command, case_id),
|
|
case_type_key=command.case_type_key,
|
|
status_key=str(case_type["initial_status_key"]),
|
|
title=command.title,
|
|
context=context,
|
|
opened_at=command.occurred_at,
|
|
recorded_at=command.occurred_at,
|
|
change_reason=change_reason,
|
|
metadata={
|
|
"source_module": "tickets",
|
|
"source_resource_type": "ticket",
|
|
"source_resource_id": command.ticket_id,
|
|
"source_resource_number": command.ticket_number,
|
|
"source_revision": str(command.metadata.get("ticket_revision") or "1"),
|
|
"handoff_note": command.handoff_note,
|
|
"integration_contract": "tickets.case_escalation/v1",
|
|
},
|
|
)
|
|
created = create_case(
|
|
db,
|
|
principal,
|
|
record=record,
|
|
idempotency_key=f"ticket-escalation:{command.idempotency_key}",
|
|
)
|
|
return _result(created, replayed=False)
|
|
|
|
|
|
def _result(record: CaseRecord, *, replayed: bool) -> TicketCaseEscalationResult:
|
|
return TicketCaseEscalationResult(
|
|
provider_id="cases",
|
|
case_id=record.reference.object_id,
|
|
case_number=record.case_number,
|
|
case_url=f"/cases/{quote(record.reference.object_id, safe='')}",
|
|
replayed=replayed,
|
|
metadata={
|
|
"case_revision": record.revision,
|
|
"case_type_key": record.case_type_key,
|
|
"status_key": record.status_key,
|
|
},
|
|
)
|
|
|
|
|
|
def _case_number(command: TicketCaseEscalationCommand, case_id: str) -> str:
|
|
prefix = f"CASE-{command.ticket_number}"[:230].rstrip("-")
|
|
return f"{prefix}-{case_id[:8].upper()}"
|
|
|
|
|
|
def _has_scope(principal: object, scope: str) -> bool:
|
|
method = getattr(principal, "has", None)
|
|
if callable(method):
|
|
return bool(method(scope))
|
|
return scopes_grant_compatible(
|
|
frozenset(getattr(principal, "scopes", ()) or ()),
|
|
scope,
|
|
)
|
|
|
|
|
|
def _session(value: object) -> Session:
|
|
if not isinstance(value, Session):
|
|
raise TypeError("Ticket Case escalation requires a SQLAlchemy session.")
|
|
return value
|
|
|
|
|
|
__all__ = ["TicketCaseEscalationProvider"]
|