feat(cases): accept replay-safe ticket escalations
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user