feat(cases): accept replay-safe ticket escalations

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent f236034e88
commit d3a70f87cd
8 changed files with 351 additions and 7 deletions
+52 -1
View File
@@ -31,6 +31,7 @@ from govoplan_core.core.modules import (
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.tickets import CAPABILITY_TICKET_CASE_ESCALATION
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
@@ -60,11 +61,12 @@ from govoplan_cases.backend.record_source import (
create_cases_record_source,
)
from govoplan_cases.backend.search_source import create_cases_search_source
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
from govoplan_core.db.base import Base
MODULE_ID = "cases"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.20"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
@@ -98,6 +100,10 @@ def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
return CasesDsarProvider()
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
return TicketCaseEscalationProvider()
def _router(context: ModuleContext):
del context
from govoplan_cases.backend.router import router
@@ -333,6 +339,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_TICKET_CASE_ESCALATION, version="1.0.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
@@ -348,6 +355,7 @@ manifest = ModuleManifest(
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
CASES_DSAR_CAPABILITY: _dsar_provider,
CAPABILITY_TICKET_CASE_ESCALATION: _ticket_escalation,
},
capability_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
@@ -380,6 +388,11 @@ manifest = ModuleManifest(
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
contract_version="0.1.0",
),
CAPABILITY_TICKET_CASE_ESCALATION: CapabilityDocumentation(
label="Ticket-to-Case escalation",
summary="Creates one replay-safe formal Case from a Ticket handoff and returns only the stable Case reference.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -417,6 +430,44 @@ manifest = ModuleManifest(
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="cases.workflow.ticket-escalation",
title="Create a formal Case from a Ticket escalation",
summary="Preserve the operational Ticket and start one replay-safe formal procedure with a stable cross-reference.",
body=(
"An authorized ticket triage actor selects an active Case type and submits a handoff note. "
"Cases rechecks the active tenant and cases:case:create permission, derives a stable Case identity from the Ticket and idempotency key, "
"uses the configured initial status, and records the exact Ticket work-item reference in the governed Case context. "
"The Ticket remains authoritative for intake, queue work, comments, and service resolution; the Case becomes authoritative for parties, evidence, decisions, deadlines, records, and retention. "
"Replaying the same request returns the existing Case. A changed request or missing/inactive Case type fails closed."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "ticket_manager", "operator", "auditor"),
related_modules=("tickets",),
order=11,
conditions=(
DocumentationCondition(
required_modules=("cases", "tickets"),
required_scopes=(CREATE_SCOPE, "tickets:ticket:triage"),
),
),
translations={
"de": {
"title": "Aus einer Ticket-Eskalation einen förmlichen Vorgang anlegen",
"summary": "Das operative Ticket erhalten und ein wiederholungssicheres förmliches Verfahren mit stabilem Querverweis starten.",
"body": "Eine berechtigte Triage wählt einen aktiven Vorgangstyp und übergibt einen Hinweis. Cases prüft den aktiven Mandanten und die Berechtigung cases:case:create erneut, leitet aus Ticket und Idempotenzschlüssel eine stabile Vorgangskennung ab, verwendet den konfigurierten Anfangsstatus und hält den genauen Ticket-Verweis im geregelten Vorgangskontext fest. Das Ticket bleibt für Aufnahme, Warteschlangenarbeit, Kommentare und Servicelösung maßgeblich; der Vorgang wird für Beteiligte, Nachweise, Entscheidungen, Fristen, Akten und Aufbewahrung maßgeblich. Dieselbe Anfrage liefert den vorhandenen Vorgang; geänderte Anfragen sowie fehlende oder inaktive Vorgangstypen werden abgewiesen.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["tickets.action.escalate", "cases.state.ticket-escalation"],
"consequence_classes": {
"create_case": "Creates one Cases-owned formal procedure and returns its stable reference.",
"preserve_ticket": "Does not convert, copy, or mutate the Ticket history.",
},
},
),
DocumentationTopic(
id="cases.workflow.link-owner-references",
title="Link exact Files, Mail, and Campaign references to a Case",
@@ -0,0 +1,161 @@
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"]