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"]