Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1cffd3e53 | ||
|
|
d395b22301 | ||
|
|
d3a70f87cd |
@@ -26,7 +26,10 @@ The executable backend slice now exposes:
|
|||||||
assignment/evidence/Decision/record references, and replay-safe lifecycle
|
assignment/evidence/Decision/record references, and replay-safe lifecycle
|
||||||
events; and
|
events; and
|
||||||
- `cases.service_launcher`, which opens exactly one deterministic case from an
|
- `cases.service_launcher`, which opens exactly one deterministic case from an
|
||||||
exact published Service revision and safely replays the same Portal launch.
|
exact published Service revision and safely replays the same Portal launch;
|
||||||
|
and
|
||||||
|
- `tickets.case_escalation`, which creates exactly one formal Case from an
|
||||||
|
authorized Ticket handoff while preserving a governed Ticket reference.
|
||||||
|
|
||||||
Cases does not own institutional Service, Party, representation, identity,
|
Cases does not own institutional Service, Party, representation, identity,
|
||||||
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
||||||
@@ -50,3 +53,14 @@ Historical case evidence is retained; current open case and active access facts
|
|||||||
require authorized manual review through the existing lifecycle. Applicant
|
require authorized manual review through the existing lifecycle. Applicant
|
||||||
identity correlation remains a Parties responsibility and is never guessed
|
identity correlation remains a Parties responsibility and is never guessed
|
||||||
from a case's party references.
|
from a case's party references.
|
||||||
|
|
||||||
|
## Ticket escalation
|
||||||
|
|
||||||
|
When Tickets requests escalation, Cases rechecks tenant membership and
|
||||||
|
`cases:case:create`, resolves the exact active Case type and its initial status,
|
||||||
|
and derives a deterministic Case ID from the Ticket and idempotency key. A retry
|
||||||
|
returns the same Case. The Case keeps only the stable Ticket reference, source
|
||||||
|
revision, number, and authorized handoff note in governed context; it does not
|
||||||
|
copy Ticket comments, attachments, participants, or lifecycle state. If Cases
|
||||||
|
is absent or the configured type is unavailable, Tickets remains usable and
|
||||||
|
shows the integration consequence to the operator.
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ The persistent backend provides:
|
|||||||
- tenant-level resource ACL and tenant summary providers
|
- tenant-level resource ACL and tenant summary providers
|
||||||
- tenant-wide or restricted object access with explicit, revisioned grants
|
- tenant-wide or restricted object access with explicit, revisioned grants
|
||||||
- deterministic, replay-safe case launch from an exact Service binding
|
- deterministic, replay-safe case launch from an exact Service binding
|
||||||
|
- deterministic, replay-safe Case escalation from an authorized Ticket handoff
|
||||||
|
|
||||||
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
|
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
|
||||||
search/status filtering, case facts, typed references, history, timeline, and
|
search/status filtering, case facts, typed references, history, timeline, and
|
||||||
@@ -159,6 +160,15 @@ reference. Retrying the same launch returns the existing case; a changed request
|
|||||||
cannot reuse the key. Portal therefore cannot create duplicate cases after an
|
cannot reuse the key. Portal therefore cannot create duplicate cases after an
|
||||||
ambiguous network response and does not gain access to Cases tables.
|
ambiguous network response and does not gain access to Cases tables.
|
||||||
|
|
||||||
|
`tickets.case_escalation` is the optional effect boundary used by Tickets. It
|
||||||
|
rechecks `cases:case:create`, tenant identity, and the requested active Case
|
||||||
|
type, then derives a stable Case identifier from the Ticket and idempotency key.
|
||||||
|
The Case stores a governed `work_item` reference plus bounded source and handoff
|
||||||
|
metadata. It never imports Tickets or copies Ticket messages, files,
|
||||||
|
participants, assignment, or lifecycle. Replaying the same handoff returns the
|
||||||
|
existing Case, while an unavailable Cases capability leaves the operational
|
||||||
|
Ticket intact.
|
||||||
|
|
||||||
Cases links formal Decision records and may retain a current outcome/status
|
Cases links formal Decision records and may retain a current outcome/status
|
||||||
projection. It does not own decision authority, rule versions, reasoning,
|
projection. It does not own decision authority, rule versions, reasoning,
|
||||||
correction, revocation, or remedy semantics.
|
correction, revocation, or remedy semantics.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/cases-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/cases.css": "./webui/src/styles/cases.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.30",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-cases"
|
name = "govoplan-cases"
|
||||||
version = "0.1.19"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN administrative case context module."
|
description = "GovOPlaN administrative case context module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.18"]
|
dependencies = ["govoplan-core>=0.1.30"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from govoplan_core.core.modules import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
|
from govoplan_core.core.tickets import CAPABILITY_TICKET_CASE_ESCALATION
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ModuleArchitectureDeclaration,
|
ModuleArchitectureDeclaration,
|
||||||
ModuleArchitectureDocumentation,
|
ModuleArchitectureDocumentation,
|
||||||
@@ -42,7 +43,10 @@ from govoplan_cases.backend.party_context import (
|
|||||||
)
|
)
|
||||||
from govoplan_cases.backend.acl import CaseAclProvider
|
from govoplan_cases.backend.acl import CaseAclProvider
|
||||||
from govoplan_cases.backend.db import models as case_models
|
from govoplan_cases.backend.db import models as case_models
|
||||||
from govoplan_cases.backend.dsar_provider import CASES_DSAR_CAPABILITY, CasesDsarProvider
|
from govoplan_cases.backend.dsar_provider import (
|
||||||
|
CASES_DSAR_CAPABILITY,
|
||||||
|
CasesDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.service_intake import (
|
from govoplan_cases.backend.service_intake import (
|
||||||
CAPABILITY_CASES_SERVICE_INTAKE,
|
CAPABILITY_CASES_SERVICE_INTAKE,
|
||||||
CaseServiceIntake,
|
CaseServiceIntake,
|
||||||
@@ -60,11 +64,12 @@ from govoplan_cases.backend.record_source import (
|
|||||||
create_cases_record_source,
|
create_cases_record_source,
|
||||||
)
|
)
|
||||||
from govoplan_cases.backend.search_source import create_cases_search_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
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "cases"
|
MODULE_ID = "cases"
|
||||||
MODULE_VERSION = "0.1.19"
|
MODULE_VERSION = "0.1.21"
|
||||||
READ_SCOPE = "cases:case:read"
|
READ_SCOPE = "cases:case:read"
|
||||||
CREATE_SCOPE = "cases:case:create"
|
CREATE_SCOPE = "cases:case:create"
|
||||||
UPDATE_SCOPE = "cases:case:update"
|
UPDATE_SCOPE = "cases:case:update"
|
||||||
@@ -98,6 +103,10 @@ def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
|
|||||||
return CasesDsarProvider()
|
return CasesDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
|
||||||
|
return TicketCaseEscalationProvider()
|
||||||
|
|
||||||
|
|
||||||
def _router(context: ModuleContext):
|
def _router(context: ModuleContext):
|
||||||
del context
|
del context
|
||||||
from govoplan_cases.backend.router import router
|
from govoplan_cases.backend.router import router
|
||||||
@@ -163,20 +172,47 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_DECISION_REGISTRY,
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
|
_permission(
|
||||||
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
|
READ_SCOPE, "View cases", "Read tenant cases and their governed history."
|
||||||
_permission(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
|
),
|
||||||
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
|
_permission(
|
||||||
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
|
CREATE_SCOPE,
|
||||||
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
|
"Create cases",
|
||||||
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
|
"Open a case from a configured case type or service intake.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ASSIGN_SCOPE,
|
||||||
|
"Assign cases",
|
||||||
|
"Change stable function, assignment, or work-item references on a case.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SHARE_SCOPE,
|
||||||
|
"Share cases",
|
||||||
|
"Restrict a case and grant object-level access to selected principals.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
role_templates=(
|
role_templates=(
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="case_manager",
|
slug="case_manager",
|
||||||
name="Case manager",
|
name="Case manager",
|
||||||
description="Create, assign, update, and close tenant cases.",
|
description="Create, assign, update, and close tenant cases.",
|
||||||
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
CREATE_SCOPE,
|
||||||
|
UPDATE_SCOPE,
|
||||||
|
ASSIGN_SCOPE,
|
||||||
|
CLOSE_SCOPE,
|
||||||
|
SHARE_SCOPE,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="case_reader",
|
slug="case_reader",
|
||||||
@@ -188,7 +224,15 @@ manifest = ModuleManifest(
|
|||||||
slug="case_administrator",
|
slug="case_administrator",
|
||||||
name="Case administrator",
|
name="Case administrator",
|
||||||
description="Configure case types and statuses and manage cases.",
|
description="Configure case types and statuses and manage cases.",
|
||||||
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
CREATE_SCOPE,
|
||||||
|
UPDATE_SCOPE,
|
||||||
|
ASSIGN_SCOPE,
|
||||||
|
CLOSE_SCOPE,
|
||||||
|
SHARE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
@@ -234,7 +278,11 @@ manifest = ModuleManifest(
|
|||||||
label="i18n:govoplan-core.product_area.services_cases",
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
icon="landmark",
|
icon="landmark",
|
||||||
description="i18n:govoplan-core.product_area.services_cases_description",
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
|
surface_ids=(
|
||||||
|
"cases.nav.cases",
|
||||||
|
"cases.route.cases",
|
||||||
|
"cases.route.cases.caseid",
|
||||||
|
),
|
||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -333,13 +381,41 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
|
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
|
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_TICKET_CASE_ESCALATION, version="1.0.0"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(
|
||||||
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
name="services.definition",
|
||||||
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
version_min="0.1.0",
|
||||||
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
version_max_exclusive="0.2.0",
|
||||||
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="parties.procedure",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="parties.representation",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="mandates.resolution",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="decisions.formal_outcome",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
|
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
|
||||||
@@ -348,6 +424,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
||||||
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
|
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
|
||||||
CASES_DSAR_CAPABILITY: _dsar_provider,
|
CASES_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
CAPABILITY_TICKET_CASE_ESCALATION: _ticket_escalation,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
||||||
@@ -380,6 +457,11 @@ manifest = ModuleManifest(
|
|||||||
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
|
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
|
||||||
contract_version="0.1.0",
|
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(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -417,6 +499,47 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="cases.workflow.link-owner-references",
|
id="cases.workflow.link-owner-references",
|
||||||
title="Link exact Files, Mail, and Campaign references to a Case",
|
title="Link exact Files, Mail, and Campaign references to a Case",
|
||||||
@@ -460,6 +583,27 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Exakte Files-, Mail- und Campaign-Verweise mit einem Vorgang verknüpfen",
|
||||||
|
"summary": (
|
||||||
|
"Einen vom zuständigen Modul autorisierten Verweis hinzufügen oder entfernen, ohne dessen Inhalt nach Cases zu kopieren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Für einen aktiven Vorgang können Files, Mail und Campaigns über den versionierten Schnellzugriff-Ergebnisvertrag eine "
|
||||||
|
"exakte mandantengebundene Auswahl zurückgeben. Cases akzeptiert nur unterstützte Kombinationen aus zuständigem Modul "
|
||||||
|
"und Verweisart, den passenden Mandanten, eine festgelegte Version und die kanonische lokale Route des zuständigen Moduls. "
|
||||||
|
"Verknüpfen und Entfernen prüfen die Änderungsberechtigung für den Vorgang erneut, verwenden erwartete Revision und "
|
||||||
|
"Idempotenz und ergänzen eine unveränderliche Vorgangshistorie. Cases speichert nur typisierte Modulkennung, exakte "
|
||||||
|
"Version und Modulroute; Dateibytes, Nachrichtenkopf oder -inhalt, Empfangende, Campaign-Inhalt oder eine frühere "
|
||||||
|
"Zugriffsentscheidung werden nie kopiert. Das Öffnen wechselt stets in das zuständige Modul, das den aktuellen Zugriff "
|
||||||
|
"erneut prüft. Wird dieses Modul deaktiviert oder der Quellzugriff später entzogen, behält der Vorgang den historischen "
|
||||||
|
"Verweis, legt aber keinen Quellinhalt offen. Das Entfernen nimmt nur den Verweis aus der nächsten Vorgangsrevision und "
|
||||||
|
"löscht niemals das Quellobjekt. Ungespeicherte Vorgangsänderungen müssen vor dem Verknüpfen eines Schnellzugriff-Ergebnisses "
|
||||||
|
"gespeichert oder verworfen werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"help_contexts": ["cases.detail.references"],
|
"help_contexts": ["cases.detail.references"],
|
||||||
@@ -489,6 +633,27 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen für Vorgänge",
|
||||||
|
"summary": (
|
||||||
|
"Gesteuerte Vorgangstatsachen ausgeben, ohne die antragstellende Identität zu erraten oder undurchsichtige "
|
||||||
|
"Vorgangsdaten offenzulegen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der DSAR-Provider von Cases gleicht im exakten Mandanten Konten-, Identitäts- und Mitgliedschaftszuordnungen, "
|
||||||
|
"Zugriffsfreigaben für Konten oder Identitäten und ausdrückliche Cases-Verweise ab. Wird ein kanonischer Selektor mit "
|
||||||
|
"einem direkten Verweis kombiniert, muss eine Cases-eigene Beziehung beide bestätigen; andernfalls schließt die Suche "
|
||||||
|
"sicher. Direkte Vorgangs- und Revisionsverweise exportieren eine typisierte, minimierte Lebenszyklusprojektion. Rohe "
|
||||||
|
"Snapshots, Metadaten, Suchtext, Freitext-Änderungsgründe, Timeline-Nutzdaten und -Zusammenfassungen, Nachweiskennungen, "
|
||||||
|
"Anfrage-Digests, Idempotenzschlüssel, Auditkennungen und unbeteiligte Zugriffsbetroffene sind ausgeschlossen. "
|
||||||
|
"Unveränderliche Vorgangskennungen, Revisionen, Timelines und Zuordnungen werden aufbewahrt. Aktuelle offene Vorgänge und "
|
||||||
|
"aktive Zugriffsmerkmale erfordern eine berechtigte manuelle Prüfung im normalen Vorgangs-/Zugriffslebenszyklus; eine "
|
||||||
|
"automatische Löschung wird nicht angeboten. Die Verbindung zwischen antragstellender Person und Identität gehört Parties; "
|
||||||
|
"Cases leitet sie daher nicht aus Beteiligtenkennungen ab."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="cases.workflow.quick-access-context",
|
id="cases.workflow.quick-access-context",
|
||||||
@@ -552,9 +717,31 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
|
label="Cases concept",
|
||||||
|
href="govoplan-cases/docs/CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Decisions domain",
|
||||||
|
href="govoplan-decisions/docs/DECISIONS_DOMAIN.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Förmliche Entscheidung aus einem Vorgang festhalten",
|
||||||
|
"summary": "Handlungsbefugnis erneut prüfen und eine exakte Decision-Revision in der Vorgangshistorie bewahren.",
|
||||||
|
"body": (
|
||||||
|
"Die Aktion in der Vorgangsdetailansicht akzeptiert nur Tenor, Begründung, Bedingungen, Wirksamkeitszeitpunkt und "
|
||||||
|
"Änderungsgrund. Cases löst die exakte Vorgangsrevision auf, prüft den objektbezogenen Änderungszugriff, ermittelt über "
|
||||||
|
"Access die aktuelle Handlungszuweisung und verlangt genau ein wirksames Mandat für cases.formal_decision und den gewählten "
|
||||||
|
"Decision-Typ. Anschließend wird das Ergebnis über das optionale Decisions-Register aufgezeichnet und die exakte "
|
||||||
|
"Decision-Revision in derselben Datenbanktransaktion mit einer neuen Vorgangsrevision verknüpft. Eine Wiederholung "
|
||||||
|
"verwendet eine deterministische Decision-Kennung; abweichende Nutzdaten schließen sicher."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
||||||
@@ -595,8 +782,24 @@ manifest = ModuleManifest(
|
|||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
DocumentationLink(label="Records", href="/records", kind="runtime"),
|
DocumentationLink(label="Records", href="/records", kind="runtime"),
|
||||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
DocumentationLink(
|
||||||
|
label="Cases concept",
|
||||||
|
href="govoplan-cases/docs/CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Exakte Vorgangsrevision in eine eAkte ablegen",
|
||||||
|
"summary": "Einen rekonstruierbaren Vorgangs-Snapshot in Records bewahren, ohne die Zuständigkeit von Cases zu verlagern.",
|
||||||
|
"body": (
|
||||||
|
"Ist Records aktiviert, löst Cases eine exakte unveränderliche Vorgangsrevision erst auf, nachdem aktuelle "
|
||||||
|
"Vorgangsberechtigung und objektbezogener Zugriff geprüft wurden. Der abgelegte Verweis enthält Vorgangsnummer, "
|
||||||
|
"Lebenszyklusstatus, dargestelltes Gültigkeitsintervall, Aufzeichnungszeitpunkt und einen SHA-256-Digest des kanonischen "
|
||||||
|
"Snapshots. Cases bleibt für den Vorgangslebenszyklus maßgeblich."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"help_contexts": ["cases.detail", "records.action.file"],
|
"help_contexts": ["cases.detail", "records.action.file"],
|
||||||
@@ -638,6 +841,23 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Institutioneller Kontext eines Vorgangs",
|
||||||
|
"summary": (
|
||||||
|
"Cases bewahrt Service- und Beteiligtenverweise, ohne institutionelle Definitionen oder Betroffenenstammdaten zu übernehmen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Vorgangstypen und -status sind Mandantenkonfiguration. Jede Anlage oder Änderung schreibt eine unveränderliche, durch "
|
||||||
|
"optimistische Nebenläufigkeitskontrolle geschützte Revision und ein wiederholungssicheres Timeline-Ereignis. Die "
|
||||||
|
"Service-Aufnahme hält exakte Bindungen an Service, Mandat, Zuständigkeit, Rechtsgrundlage, Formular, Workflow und Ergebnis "
|
||||||
|
"fest. Verfahrensbeteiligte stammen von einem optionalen Provider oder einer begrenzten Cases-Kompatibilitätsprojektion. "
|
||||||
|
"Zuweisungs-, Nachweis-, Decision- und Aktenverweise bleiben stabile Referenzen im Eigentum ihrer Quellmodule. Ist Search "
|
||||||
|
"aktiviert, liefert Cases eine neu aufbaubare reine Metadatenprojektion. Nachweise und geschützte Decision-Inhalte sind "
|
||||||
|
"ausgeschlossen; jeder Treffer wird vor der Offenlegung erneut gegen den aktuellen Vorgangszugriff geprüft."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"cases.list",
|
"cases.list",
|
||||||
@@ -674,7 +894,26 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Referenz für Vorgangslebenszyklus, Zugriff und Nachweise",
|
||||||
|
"summary": (
|
||||||
|
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
|
||||||
|
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
|
||||||
|
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
|
||||||
|
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur durch administrative Befugnis, "
|
||||||
|
"Zuweisungs- oder Einheitenkontext, Erstellerbefugnis oder eine ausdrückliche Konto-/Gruppenfreigabe sichtbar. "
|
||||||
|
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
|
||||||
|
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
|
||||||
|
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"cases.field.title",
|
"cases.field.title",
|
||||||
"cases.field.status",
|
"cases.field.status",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
|
|
||||||
|
|
||||||
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in get_manifest().documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||||
frontend = get_manifest().frontend
|
frontend = get_manifest().frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -53,15 +61,16 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("change_access", reference.metadata["consequence_classes"])
|
self.assertIn("change_access", reference.metadata["consequence_classes"])
|
||||||
|
self.assertEqual("reference", reference.metadata["kind"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"link_owner_reference",
|
"link_owner_reference",
|
||||||
owner_links.metadata["consequence_classes"],
|
owner_links.metadata["consequence_classes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||||
list_page = (
|
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
|
||||||
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
|
encoding="utf-8"
|
||||||
).read_text(encoding="utf-8")
|
)
|
||||||
detail_page = (
|
detail_page = (
|
||||||
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
|
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.tickets import TicketCaseEscalationCommand
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_cases.backend.db import models as _case_models
|
||||||
|
from govoplan_cases.backend.service import upsert_case_status, upsert_case_type
|
||||||
|
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
def __init__(self, tenant_id: str = "tenant-1", *, can_create: bool = True) -> None:
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.account_id = "case-manager-1"
|
||||||
|
self.identity_id = None
|
||||||
|
self.membership_id = "membership-1"
|
||||||
|
self.group_ids = frozenset()
|
||||||
|
self.role_ids = frozenset()
|
||||||
|
self.function_assignment_ids = frozenset()
|
||||||
|
self.acting_assignment_id = None
|
||||||
|
self.scopes = frozenset({"cases:case:create", "cases:case:read"} if can_create else {"cases:case:read"})
|
||||||
|
self.user = SimpleNamespace(id=self.account_id)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEscalationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
self.principal = _Principal()
|
||||||
|
|
||||||
|
def test_escalation_creates_one_linked_case_and_replays(self) -> None:
|
||||||
|
with self.Session() as session:
|
||||||
|
upsert_case_status(
|
||||||
|
session,
|
||||||
|
self.principal,
|
||||||
|
status_key="open",
|
||||||
|
label="Open",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
session,
|
||||||
|
self.principal,
|
||||||
|
type_key="service-request",
|
||||||
|
label="Service request",
|
||||||
|
initial_status_key="open",
|
||||||
|
)
|
||||||
|
command = TicketCaseEscalationCommand(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
ticket_id="ticket-1",
|
||||||
|
ticket_number="TKT-2026-0001",
|
||||||
|
title="Broken streetlight",
|
||||||
|
case_type_key="service-request",
|
||||||
|
occurred_at=NOW,
|
||||||
|
idempotency_key="escalation-1",
|
||||||
|
handoff_note="Formal procedure required.",
|
||||||
|
metadata={"ticket_revision": 3},
|
||||||
|
)
|
||||||
|
provider = TicketCaseEscalationProvider()
|
||||||
|
|
||||||
|
created = provider.escalate_ticket(session, self.principal, command=command)
|
||||||
|
session.commit()
|
||||||
|
replay = provider.escalate_ticket(session, self.principal, command=command)
|
||||||
|
|
||||||
|
self.assertFalse(created.replayed)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(created.case_id, replay.case_id)
|
||||||
|
self.assertEqual("service-request", created.metadata["case_type_key"])
|
||||||
|
self.assertTrue(created.case_url.startswith("/cases/"))
|
||||||
|
self.assertEqual(1, session.query(_case_models.CaseIdentity).count())
|
||||||
|
current = session.query(_case_models.CaseRecordRevision).filter(
|
||||||
|
_case_models.CaseRecordRevision.case_id == created.case_id,
|
||||||
|
_case_models.CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
).one()
|
||||||
|
self.assertEqual("tickets", current.snapshot["context"]["work_item_ref"]["owner_module"])
|
||||||
|
self.assertEqual("ticket-1", current.snapshot["metadata"]["source_resource_id"])
|
||||||
|
|
||||||
|
def test_escalation_fails_closed_for_cross_tenant_or_missing_permission(self) -> None:
|
||||||
|
command = TicketCaseEscalationCommand(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
ticket_id="ticket-1",
|
||||||
|
ticket_number="TKT-1",
|
||||||
|
title="Request",
|
||||||
|
case_type_key="service-request",
|
||||||
|
occurred_at=NOW,
|
||||||
|
idempotency_key="escalation-2",
|
||||||
|
)
|
||||||
|
provider = TicketCaseEscalationProvider()
|
||||||
|
with self.Session() as session:
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
provider.escalate_ticket(session, _Principal("tenant-2"), command=command)
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
provider.escalate_ticket(session, _Principal(can_create=False), command=command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/cases-webui",
|
"name": "@govoplan/cases-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/cases.css": "./src/styles/cases.css"
|
"./styles/cases.css": "./src/styles/cases.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.30",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
|
|||||||
export const casesModule: PlatformWebModule = {
|
export const casesModule: PlatformWebModule = {
|
||||||
id: "cases",
|
id: "cases",
|
||||||
label: "i18n:govoplan-cases.cases",
|
label: "i18n:govoplan-cases.cases",
|
||||||
version: "0.1.8",
|
version: "0.1.21",
|
||||||
optionalDependencies: [
|
optionalDependencies: [
|
||||||
"access",
|
"access",
|
||||||
"addresses",
|
"addresses",
|
||||||
|
|||||||
Reference in New Issue
Block a user