Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d395b22301 | ||
|
|
d3a70f87cd | ||
|
|
f236034e88 | ||
|
|
eab2f4b3b2 | ||
|
|
fd7f05ffc9 | ||
|
|
2efe574f18 | ||
|
|
83e69565fe | ||
|
|
86878ef7a7 | ||
|
|
23f5091a2b | ||
|
|
2f030e56a9 | ||
|
|
cfe2389e5a | ||
|
|
0265e135f5 |
@@ -26,7 +26,10 @@ The executable backend slice now exposes:
|
||||
assignment/evidence/Decision/record references, and replay-safe lifecycle
|
||||
events; and
|
||||
- `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,
|
||||
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
||||
@@ -38,3 +41,26 @@ uses the shared reference selector to manage those grants; list, detail,
|
||||
history, timeline, and update paths all apply the same fail-closed ACL.
|
||||
|
||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants
|
||||
and operator attribution, plus minimized case lifecycle data when an explicit
|
||||
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
|
||||
text, free-text reasons, event payloads, evidence identifiers, request digests,
|
||||
idempotency keys, audit internals, and unrelated access subjects are excluded.
|
||||
Historical case evidence is retained; current open case and active access facts
|
||||
require authorized manual review through the existing lifecycle. Applicant
|
||||
identity correlation remains a Parties responsibility and is never guessed
|
||||
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.
|
||||
|
||||
+57
-2
@@ -90,6 +90,7 @@ The persistent backend provides:
|
||||
- tenant-level resource ACL and tenant summary providers
|
||||
- tenant-wide or restricted object access with explicit, revisioned grants
|
||||
- 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
|
||||
search/status filtering, case facts, typed references, history, timeline, and
|
||||
@@ -159,13 +160,44 @@ reference. Retrying the same launch returns the existing case; a changed request
|
||||
cannot reuse the key. Portal therefore cannot create duplicate cases after an
|
||||
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
|
||||
projection. It does not own decision authority, rule versions, reasoning,
|
||||
correction, revocation, or remedy semantics.
|
||||
|
||||
The Case detail decision path is a guarded composition boundary. It resolves an
|
||||
exact Case revision, rechecks Case update access, verifies the current acting
|
||||
assignment through Access, and requires exactly one effective Mandate for
|
||||
`cases.formal_decision` and the selected Decision type. Only then does it call
|
||||
the optional Decisions registry and append the exact Decision reference to a
|
||||
new Case revision in the same transaction. Deterministic identifiers make an
|
||||
unchanged retry safe, while changed replay payloads and stale Case revisions
|
||||
fail closed. Cases still does not store the protected result or reasoning.
|
||||
|
||||
Evidence links should store only stable module/resource references and display
|
||||
metadata snapshots. The owning module remains responsible for the real object.
|
||||
|
||||
When Records is enabled, `records.source.cases` resolves one exact immutable
|
||||
case revision for eAkte filing. Cases rechecks current tenant, scope, and
|
||||
object-level access, then returns the case number, lifecycle state, represented
|
||||
valid interval, recorded time, canonical snapshot digest, and launch link.
|
||||
Records owns the filing decision and chronology; Cases remains authoritative
|
||||
for the case and its revision history.
|
||||
|
||||
When Search is enabled, `cases.cases` indexes current Case titles, numbers,
|
||||
types, states, and the existing bounded search projection. It includes no
|
||||
provider-owned evidence content or protected Decision reasoning. Restricted
|
||||
Case candidates carry bounded ACL tokens and every result is checked again
|
||||
against the current Case object-access decision before disclosure.
|
||||
|
||||
## WebUI
|
||||
|
||||
Initial route contributions:
|
||||
@@ -173,8 +205,11 @@ Initial route contributions:
|
||||
- `/cases`
|
||||
- `/cases/:caseId`
|
||||
|
||||
The initial detail view renders the module-owned record, stable references,
|
||||
history, and timeline. Future linked forms, files, tasks, workflow state,
|
||||
The detail view renders the module-owned record, stable references, history,
|
||||
timeline, and provider-resolved formal Decisions when currently authorized. It
|
||||
can record a direct officer Decision when Access, Mandates, and Decisions are
|
||||
enabled and can launch exact Case or Decision filing when Records is enabled.
|
||||
Future linked forms, files, tasks, workflow state,
|
||||
appointments, documents, communication, payment evidence, and richer audit
|
||||
panels must arrive through declarative extension points, without direct UI
|
||||
imports from sibling modules.
|
||||
@@ -192,6 +227,26 @@ The focused suite covers:
|
||||
- explicit and assignment-derived case access, including non-disclosure
|
||||
- exact Service launch, deterministic replay, and conflict behavior
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
|
||||
membership selectors cover Cases-owned operator attribution; account and
|
||||
identity selectors also cover explicit case-access grants. Exact
|
||||
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
|
||||
references select lifecycle data. When a canonical and direct selector are
|
||||
combined, a Cases-owned relationship must corroborate them and all supplied
|
||||
direct references must identify the same case.
|
||||
|
||||
Direct case results are typed projections rather than stored snapshots. They
|
||||
exclude opaque metadata, search text, free-text change reasons, timeline
|
||||
payloads and summaries, evidence identifiers, request hashes, idempotency
|
||||
keys, audit identifiers, and unrelated access subjects. Applicant-to-identity
|
||||
correlation is owned by Parties; Cases does not infer it from opaque party
|
||||
identifiers. Immutable case identities, revisions, timelines, and operator
|
||||
attribution receive retention actions. Current open case facts and active
|
||||
access grants receive non-executable manual-review actions and can only be
|
||||
changed through the authorized case/access lifecycle.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Whether comments belong in cases, tasks, or a collaboration module.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.20",
|
||||
"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]
|
||||
name = "govoplan-cases"
|
||||
version = "0.1.16"
|
||||
version = "0.1.20"
|
||||
description = "GovOPlaN administrative case context module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.16"]
|
||||
dependencies = ["govoplan-core>=0.1.30"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
ActorRepresentationReference,
|
||||
DecisionRegistry,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
MandateResolutionRequest,
|
||||
MandateResolver,
|
||||
TemporalRevision,
|
||||
resolve_mandate_candidates,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.service import (
|
||||
can_access_case,
|
||||
get_case,
|
||||
update_case,
|
||||
)
|
||||
|
||||
|
||||
DECISION_READ_SCOPE = "decisions:decision:read"
|
||||
DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive"
|
||||
DECISION_WRITE_SCOPE = "decisions:decision:write"
|
||||
_DECISION_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
"https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1",
|
||||
)
|
||||
|
||||
|
||||
class CaseDecisionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CaseDecisionUnavailable(CaseDecisionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaseDecisionCommand:
|
||||
expected_case_revision: int
|
||||
effective_at: datetime
|
||||
decision_type: str
|
||||
operative_result: str
|
||||
reasoning: str
|
||||
change_reason: str
|
||||
idempotency_key: str
|
||||
conditions: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaseDecisionResult:
|
||||
case: CaseRecord
|
||||
decision: FormalDecision
|
||||
replayed: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"case": self.case.to_dict(),
|
||||
"decision": self.decision.to_dict(include_protected=True),
|
||||
"replayed": self.replayed,
|
||||
}
|
||||
|
||||
|
||||
class CaseDecisionPath:
|
||||
"""Record a formal outcome and its exact Case reference atomically."""
|
||||
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def record(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
command: CaseDecisionCommand,
|
||||
) -> CaseDecisionResult:
|
||||
_validate_command(command)
|
||||
source_case = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=command.expected_case_revision,
|
||||
)
|
||||
current = get_case(session, principal, case_id=case_id)
|
||||
if source_case is None or current is None:
|
||||
raise LookupError("Case not found.")
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="update",
|
||||
):
|
||||
raise PermissionError("Case update access is denied.")
|
||||
|
||||
decision_id = _decision_id(source_case, command.idempotency_key)
|
||||
existing_ref = next(
|
||||
(
|
||||
item
|
||||
for item in current.decision_refs
|
||||
if item.owner_module == "decisions" and item.object_id == decision_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_ref is None and current.revision != command.expected_case_revision:
|
||||
raise CaseDecisionError(
|
||||
"Case revision conflict: reload the Case before recording a Decision."
|
||||
)
|
||||
|
||||
decision_registry = _decision_registry(self._registry)
|
||||
mandate = _resolve_mandate(
|
||||
self._registry,
|
||||
session,
|
||||
principal,
|
||||
case=source_case,
|
||||
command=command,
|
||||
)
|
||||
actor = _resolve_actor(
|
||||
self._registry,
|
||||
principal,
|
||||
case=source_case,
|
||||
mandate=mandate,
|
||||
effective_at=command.effective_at,
|
||||
)
|
||||
decision = _decision(
|
||||
source_case,
|
||||
command=command,
|
||||
decision_id=decision_id,
|
||||
mandate=mandate,
|
||||
actor=actor,
|
||||
)
|
||||
persisted = decision_registry.record_decision(
|
||||
session,
|
||||
principal,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
if existing_ref is not None:
|
||||
return CaseDecisionResult(
|
||||
case=current,
|
||||
decision=persisted,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
linked = update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=current.revision,
|
||||
changes={"decision_refs": (*current.decision_refs, persisted.reference)},
|
||||
recorded_at=command.effective_at,
|
||||
change_reason=command.change_reason,
|
||||
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
||||
)
|
||||
return CaseDecisionResult(case=linked, decision=persisted)
|
||||
|
||||
def linked(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
) -> tuple[FormalDecision, ...]:
|
||||
case = get_case(session, principal, case_id=case_id)
|
||||
if case is None:
|
||||
raise LookupError("Case not found.")
|
||||
registry = _decision_registry(self._registry)
|
||||
resolved: list[FormalDecision] = []
|
||||
for reference in case.decision_refs:
|
||||
if reference.owner_module != "decisions":
|
||||
continue
|
||||
item = registry.get_decision(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if item is not None:
|
||||
resolved.append(item)
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _decision(
|
||||
case: CaseRecord,
|
||||
*,
|
||||
command: CaseDecisionCommand,
|
||||
decision_id: str,
|
||||
mandate: MandateDefinition,
|
||||
actor: ActorRepresentationReference,
|
||||
) -> FormalDecision:
|
||||
evidence = tuple(
|
||||
dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence))
|
||||
)
|
||||
legal_bases = tuple(
|
||||
dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases))
|
||||
)
|
||||
if not evidence:
|
||||
raise CaseDecisionError(
|
||||
"A formal Decision requires evidence linked to the exact Case revision."
|
||||
)
|
||||
if not legal_bases:
|
||||
raise CaseDecisionError(
|
||||
"A formal Decision requires an effective legal-basis version."
|
||||
)
|
||||
decision_ref = InstitutionalReference(
|
||||
kind="decision",
|
||||
owner_module="decisions",
|
||||
object_id=decision_id,
|
||||
tenant_id=case.reference.tenant_id,
|
||||
version="1",
|
||||
valid_at=command.effective_at,
|
||||
)
|
||||
temporal = TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=command.effective_at,
|
||||
recorded_at=command.effective_at,
|
||||
change_reason=command.change_reason,
|
||||
)
|
||||
authority_context: GovernedContextEnvelope = replace(
|
||||
case.context,
|
||||
temporal=temporal,
|
||||
actor=actor,
|
||||
mandate_ref=mandate.reference,
|
||||
case_ref=case.reference,
|
||||
party_refs=case.party_refs or case.context.party_refs,
|
||||
decision_ref=decision_ref,
|
||||
record_refs=case.record_refs,
|
||||
legal_bases=legal_bases,
|
||||
evidence=evidence,
|
||||
)
|
||||
return FormalDecision(
|
||||
reference=decision_ref,
|
||||
temporal=temporal,
|
||||
decision_type=command.decision_type.strip(),
|
||||
subject_refs=case.party_refs or (case.reference,),
|
||||
state="decided",
|
||||
authority_context=authority_context,
|
||||
fact_evidence=evidence,
|
||||
legal_bases=legal_bases,
|
||||
operative_result=command.operative_result.strip(),
|
||||
reasoning=command.reasoning.strip(),
|
||||
conditions=tuple(item.strip() for item in command.conditions if item.strip()),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mandate(
|
||||
registry: object | None,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
command: CaseDecisionCommand,
|
||||
) -> MandateDefinition:
|
||||
resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER)
|
||||
if not isinstance(resolver, MandateResolver):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require an enabled Mandates resolver."
|
||||
)
|
||||
request = MandateResolutionRequest(
|
||||
tenant_id=case.reference.tenant_id,
|
||||
effective_at=command.effective_at,
|
||||
task_type="cases.formal_decision",
|
||||
authority_type=command.decision_type.strip(),
|
||||
organization_unit_ref=case.context.organization_unit_ref,
|
||||
function_ref=case.context.function_ref,
|
||||
jurisdiction_refs=case.context.jurisdiction_refs,
|
||||
)
|
||||
resolution: MandateResolution = resolver.resolve_mandate(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
if not resolution.competent:
|
||||
raise CaseDecisionError(
|
||||
resolution.explanation or "The acting function is not competent to decide."
|
||||
)
|
||||
if resolution.conflict_refs:
|
||||
raise CaseDecisionError(
|
||||
"Mandate resolution has unresolved conflicts: "
|
||||
+ ", ".join(resolution.conflict_refs)
|
||||
)
|
||||
verified = resolve_mandate_candidates(request, resolution.mandates)
|
||||
if not verified.competent or len(verified.mandates) != 1:
|
||||
raise CaseDecisionError(
|
||||
verified.explanation
|
||||
or "A formal Decision requires exactly one effective active Mandate."
|
||||
)
|
||||
return verified.mandates[0]
|
||||
|
||||
|
||||
def _resolve_actor(
|
||||
registry: object | None,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
mandate: MandateDefinition,
|
||||
effective_at: datetime,
|
||||
) -> ActorRepresentationReference:
|
||||
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||
lookup = getattr(directory, "get_function_assignment", None)
|
||||
if not callable(lookup):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require the Access semantic directory to verify the acting assignment."
|
||||
)
|
||||
acting_id = _text(getattr(principal, "acting_assignment_id", None))
|
||||
granted_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(item).strip()
|
||||
for item in (getattr(principal, "function_assignment_ids", ()) or ())
|
||||
if str(item).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if acting_id and acting_id not in granted_ids:
|
||||
raise PermissionError("The selected acting assignment is not active for this account.")
|
||||
candidate_ids = (acting_id,) if acting_id else granted_ids
|
||||
matching = []
|
||||
for assignment_id in candidate_ids:
|
||||
assignment = lookup(assignment_id)
|
||||
if assignment is None or not _assignment_matches(
|
||||
assignment,
|
||||
principal,
|
||||
case=case,
|
||||
effective_at=effective_at,
|
||||
):
|
||||
continue
|
||||
matching.append(assignment)
|
||||
if not matching:
|
||||
raise PermissionError(
|
||||
"No active acting assignment matches the Case organization and function."
|
||||
)
|
||||
if len(matching) > 1:
|
||||
raise CaseDecisionError(
|
||||
"Select one acting assignment in the title bar before recording the Decision."
|
||||
)
|
||||
assignment = matching[0]
|
||||
return ActorRepresentationReference(
|
||||
tenant_id=case.reference.tenant_id,
|
||||
account_id=_required_principal_text(principal, "account_id"),
|
||||
identity_id=_text(getattr(principal, "identity_id", None))
|
||||
or _text(getattr(assignment, "identity_id", None)),
|
||||
represented_account_id=_text(
|
||||
getattr(principal, "acting_for_account_id", None)
|
||||
),
|
||||
represented_function_ref=case.context.function_ref,
|
||||
function_assignment_ref=InstitutionalReference(
|
||||
kind="function_assignment",
|
||||
owner_module="access",
|
||||
object_id=str(getattr(assignment, "id")),
|
||||
tenant_id=case.reference.tenant_id,
|
||||
valid_at=effective_at,
|
||||
),
|
||||
delegation_ref=_text(
|
||||
getattr(assignment, "delegated_from_assignment_id", None)
|
||||
),
|
||||
mandate_ref=mandate.reference,
|
||||
)
|
||||
|
||||
|
||||
def _assignment_matches(
|
||||
assignment: object,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
effective_at: datetime,
|
||||
) -> bool:
|
||||
organization = case.context.organization_unit_ref
|
||||
function = case.context.function_ref
|
||||
if organization is None or function is None:
|
||||
raise CaseDecisionError(
|
||||
"The Case lacks the responsible organization and function required for a formal Decision."
|
||||
)
|
||||
if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id:
|
||||
return False
|
||||
if str(getattr(assignment, "account_id", "")) != _required_principal_text(
|
||||
principal, "account_id"
|
||||
):
|
||||
return False
|
||||
if str(getattr(assignment, "function_id", "")) != function.object_id:
|
||||
return False
|
||||
if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id:
|
||||
return False
|
||||
if str(getattr(assignment, "status", "active")) != "active":
|
||||
return False
|
||||
valid_from = getattr(assignment, "valid_from", None)
|
||||
valid_until = getattr(assignment, "valid_until", None)
|
||||
return not (
|
||||
(valid_from is not None and effective_at < valid_from)
|
||||
or (valid_until is not None and effective_at >= valid_until)
|
||||
)
|
||||
|
||||
|
||||
def _decision_registry(registry: object | None) -> DecisionRegistry:
|
||||
provider = _capability(registry, CAPABILITY_DECISION_REGISTRY)
|
||||
if not isinstance(provider, DecisionRegistry):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require an enabled Decision registry."
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _decision_id(case: CaseRecord, idempotency_key: str) -> str:
|
||||
return str(
|
||||
uuid.uuid5(
|
||||
_DECISION_NAMESPACE,
|
||||
":".join(
|
||||
(
|
||||
case.reference.tenant_id,
|
||||
case.reference.object_id,
|
||||
str(case.revision),
|
||||
idempotency_key.strip(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_command(command: CaseDecisionCommand) -> None:
|
||||
for value, label in (
|
||||
(command.decision_type, "Decision type"),
|
||||
(command.operative_result, "Operative result"),
|
||||
(command.reasoning, "Decision reasoning"),
|
||||
(command.change_reason, "Decision change reason"),
|
||||
(command.idempotency_key, "Decision idempotency key"),
|
||||
):
|
||||
if not value.strip():
|
||||
raise CaseDecisionError(f"{label} is required.")
|
||||
if command.expected_case_revision < 1:
|
||||
raise CaseDecisionError("Expected Case revision must be positive.")
|
||||
if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None:
|
||||
raise CaseDecisionError("Decision effective_at must include a timezone.")
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _text(value: object | None) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _required_principal_text(principal: object, name: str) -> str:
|
||||
value = _text(getattr(principal, name, None))
|
||||
if value is None:
|
||||
raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CaseDecisionCommand",
|
||||
"CaseDecisionError",
|
||||
"CaseDecisionPath",
|
||||
"CaseDecisionResult",
|
||||
"CaseDecisionUnavailable",
|
||||
"DECISION_READ_SCOPE",
|
||||
"DECISION_SENSITIVE_READ_SCOPE",
|
||||
"DECISION_WRITE_SCOPE",
|
||||
]
|
||||
@@ -0,0 +1,729 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseTimelineEntry,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
|
||||
|
||||
CASES_DSAR_CAPABILITY = dsar_capability_name("cases")
|
||||
_MAX_RECORDS = 5_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str | None
|
||||
identity_id: str | None
|
||||
membership_id: str | None
|
||||
case_id: str | None
|
||||
revision_id: str | None
|
||||
access_grant_id: str | None
|
||||
timeline_id: str | None
|
||||
|
||||
@property
|
||||
def actor_ids(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
value
|
||||
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||
if value
|
||||
)
|
||||
|
||||
@property
|
||||
def has_canonical_selector(self) -> bool:
|
||||
return bool(self.actor_ids)
|
||||
|
||||
@property
|
||||
def has_direct_selector(self) -> bool:
|
||||
return bool(
|
||||
self.case_id or self.revision_id or self.access_grant_id or self.timeline_id
|
||||
)
|
||||
|
||||
@property
|
||||
def has_recognized_selector(self) -> bool:
|
||||
return self.has_canonical_selector or self.has_direct_selector
|
||||
|
||||
|
||||
class CasesDsarProvider:
|
||||
provider_id = "cases"
|
||||
module_id = "cases"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None or not selectors.has_recognized_selector:
|
||||
return ()
|
||||
|
||||
identities = _matching_identities(db, tenant_id, selectors)
|
||||
revisions = _matching_revisions(db, tenant_id, selectors)
|
||||
grants = _matching_grants(db, tenant_id, selectors)
|
||||
timeline = _matching_timeline(db, tenant_id, selectors)
|
||||
if _direct_reference_conflicts(
|
||||
selectors,
|
||||
identities=identities,
|
||||
revisions=revisions,
|
||||
grants=grants,
|
||||
timeline=timeline,
|
||||
):
|
||||
return ()
|
||||
|
||||
direct_case_ids = _direct_case_ids(
|
||||
selectors,
|
||||
identities=identities,
|
||||
revisions=revisions,
|
||||
grants=grants,
|
||||
timeline=timeline,
|
||||
)
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
key = (record.resource_type, record.resource_id)
|
||||
if key in seen:
|
||||
return
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Cases DSAR result limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
seen.add(key)
|
||||
records.append(record)
|
||||
|
||||
for identity in identities:
|
||||
if identity.case_id in direct_case_ids:
|
||||
append(_case_identity_record(identity))
|
||||
if identity.created_by in selectors.actor_ids:
|
||||
append(
|
||||
_operator_record(
|
||||
resource_id=f"identity:{identity.id}",
|
||||
case_id=identity.case_id,
|
||||
activity="created_case_identity",
|
||||
observed_at=identity.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
for revision in revisions:
|
||||
if _revision_is_direct(revision, selectors, direct_case_ids):
|
||||
record = CaseRecord.from_mapping(revision.snapshot)
|
||||
append(_revision_record(revision, record))
|
||||
if revision.superseded_at is None and revision.closed_at is None:
|
||||
append(_current_fact_record(revision, record))
|
||||
if revision.changed_by in selectors.actor_ids:
|
||||
append(
|
||||
_operator_record(
|
||||
resource_id=f"revision:{revision.id}",
|
||||
case_id=revision.case_id,
|
||||
activity="changed_case_revision",
|
||||
observed_at=revision.recorded_at,
|
||||
case_revision=revision.revision,
|
||||
)
|
||||
)
|
||||
|
||||
for grant in grants:
|
||||
if _grant_subject_matches(grant, selectors) or (
|
||||
_grant_is_direct(grant, selectors)
|
||||
and not selectors.has_canonical_selector
|
||||
):
|
||||
append(_access_grant_record(grant))
|
||||
if grant.created_by in selectors.actor_ids:
|
||||
append(
|
||||
_operator_record(
|
||||
resource_id=f"grant:{grant.id}",
|
||||
case_id=grant.case_id,
|
||||
activity="changed_case_access_grant",
|
||||
observed_at=grant.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
for entry in timeline:
|
||||
direct = _timeline_is_direct(entry, selectors, direct_case_ids)
|
||||
actor_match = entry.actor_id in selectors.actor_ids
|
||||
if direct or actor_match:
|
||||
append(
|
||||
_timeline_record(
|
||||
entry,
|
||||
expose_actor=actor_match,
|
||||
match_fields=(
|
||||
(["reference"] if direct else [])
|
||||
+ (["actor_id"] if actor_match else [])
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Cases DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
if record.immutable_evidence:
|
||||
kind = "retain"
|
||||
title = f"Retain {record.title}"
|
||||
rationale = record.retention_reason or (
|
||||
"Case history is retained as institutional evidence."
|
||||
)
|
||||
else:
|
||||
kind = "manual_review"
|
||||
title = f"Review {record.title}"
|
||||
rationale = (
|
||||
"An authorized case operator must amend, close, supersede, or "
|
||||
"deactivate the current fact through the governed case lifecycle "
|
||||
"after reviewing legal, procedural, access, and third-party effects."
|
||||
)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"cases:{kind}:{record.resource_type}:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind=kind,
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=title,
|
||||
rationale=rationale,
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Cases DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable:
|
||||
raise ValueError(
|
||||
"Cases DSAR does not publish executable erasure actions."
|
||||
)
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Use the governed case or access lifecycle after legal, "
|
||||
"institutional-evidence, and third-party review."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _matching_identities(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[CaseIdentity]:
|
||||
conditions = []
|
||||
if selectors.actor_ids:
|
||||
conditions.append(CaseIdentity.created_by.in_(selectors.actor_ids))
|
||||
if selectors.case_id:
|
||||
conditions.append(CaseIdentity.case_id == selectors.case_id)
|
||||
return _query_conditions(session, CaseIdentity, tenant_id, conditions)
|
||||
|
||||
|
||||
def _matching_revisions(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[CaseRecordRevision]:
|
||||
conditions = []
|
||||
if selectors.actor_ids:
|
||||
conditions.append(CaseRecordRevision.changed_by.in_(selectors.actor_ids))
|
||||
if selectors.case_id:
|
||||
conditions.append(CaseRecordRevision.case_id == selectors.case_id)
|
||||
if selectors.revision_id:
|
||||
conditions.append(CaseRecordRevision.id == selectors.revision_id)
|
||||
return _query_conditions(session, CaseRecordRevision, tenant_id, conditions)
|
||||
|
||||
|
||||
def _matching_grants(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[CaseAccessGrant]:
|
||||
conditions = []
|
||||
for kind, value in (
|
||||
("account", selectors.account_id),
|
||||
("identity", selectors.identity_id),
|
||||
):
|
||||
if value:
|
||||
conditions.append(
|
||||
(CaseAccessGrant.subject_kind == kind)
|
||||
& (CaseAccessGrant.subject_id == value)
|
||||
)
|
||||
if selectors.actor_ids:
|
||||
conditions.append(CaseAccessGrant.created_by.in_(selectors.actor_ids))
|
||||
if selectors.case_id:
|
||||
conditions.append(CaseAccessGrant.case_id == selectors.case_id)
|
||||
if selectors.access_grant_id:
|
||||
conditions.append(CaseAccessGrant.id == selectors.access_grant_id)
|
||||
return _query_conditions(session, CaseAccessGrant, tenant_id, conditions)
|
||||
|
||||
|
||||
def _matching_timeline(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[CaseTimelineEntry]:
|
||||
conditions = []
|
||||
if selectors.actor_ids:
|
||||
conditions.append(CaseTimelineEntry.actor_id.in_(selectors.actor_ids))
|
||||
if selectors.case_id:
|
||||
conditions.append(CaseTimelineEntry.case_id == selectors.case_id)
|
||||
if selectors.timeline_id:
|
||||
conditions.append(CaseTimelineEntry.id == selectors.timeline_id)
|
||||
return _query_conditions(session, CaseTimelineEntry, tenant_id, conditions)
|
||||
|
||||
|
||||
def _direct_reference_conflicts(
|
||||
selectors: _SubjectSelectors,
|
||||
*,
|
||||
identities: Sequence[CaseIdentity],
|
||||
revisions: Sequence[CaseRecordRevision],
|
||||
grants: Sequence[CaseAccessGrant],
|
||||
timeline: Sequence[CaseTimelineEntry],
|
||||
) -> bool:
|
||||
referenced_case_ids: list[str] = []
|
||||
if selectors.case_id:
|
||||
if not any(row.case_id == selectors.case_id for row in identities):
|
||||
return True
|
||||
referenced_case_ids.append(selectors.case_id)
|
||||
if selectors.revision_id:
|
||||
row = next(
|
||||
(item for item in revisions if item.id == selectors.revision_id), None
|
||||
)
|
||||
if row is None:
|
||||
return True
|
||||
referenced_case_ids.append(row.case_id)
|
||||
if selectors.access_grant_id:
|
||||
row = next(
|
||||
(item for item in grants if item.id == selectors.access_grant_id), None
|
||||
)
|
||||
if row is None:
|
||||
return True
|
||||
if selectors.has_canonical_selector and not _grant_subject_matches(
|
||||
row, selectors
|
||||
):
|
||||
return True
|
||||
referenced_case_ids.append(row.case_id)
|
||||
if selectors.timeline_id:
|
||||
row = next(
|
||||
(item for item in timeline if item.id == selectors.timeline_id), None
|
||||
)
|
||||
if row is None:
|
||||
return True
|
||||
if selectors.has_canonical_selector and row.actor_id not in selectors.actor_ids:
|
||||
return True
|
||||
referenced_case_ids.append(row.case_id)
|
||||
if len(set(referenced_case_ids)) > 1:
|
||||
return True
|
||||
if not selectors.has_canonical_selector or not referenced_case_ids:
|
||||
return False
|
||||
related_case_ids = {
|
||||
row.case_id for row in identities if row.created_by in selectors.actor_ids
|
||||
}
|
||||
related_case_ids.update(
|
||||
row.case_id for row in revisions if row.changed_by in selectors.actor_ids
|
||||
)
|
||||
related_case_ids.update(
|
||||
row.case_id for row in grants if _grant_subject_matches(row, selectors)
|
||||
)
|
||||
related_case_ids.update(
|
||||
row.case_id for row in timeline if row.actor_id in selectors.actor_ids
|
||||
)
|
||||
return referenced_case_ids[0] not in related_case_ids
|
||||
|
||||
|
||||
def _direct_case_ids(
|
||||
selectors: _SubjectSelectors,
|
||||
*,
|
||||
identities: Sequence[CaseIdentity],
|
||||
revisions: Sequence[CaseRecordRevision],
|
||||
grants: Sequence[CaseAccessGrant],
|
||||
timeline: Sequence[CaseTimelineEntry],
|
||||
) -> set[str]:
|
||||
values = {selectors.case_id} if selectors.case_id else set()
|
||||
values.update(row.case_id for row in revisions if row.id == selectors.revision_id)
|
||||
values.update(row.case_id for row in grants if row.id == selectors.access_grant_id)
|
||||
values.update(row.case_id for row in timeline if row.id == selectors.timeline_id)
|
||||
values.update(
|
||||
row.case_id
|
||||
for row in identities
|
||||
if selectors.case_id and row.case_id == selectors.case_id
|
||||
)
|
||||
return {value for value in values if value}
|
||||
|
||||
|
||||
def _revision_is_direct(
|
||||
row: CaseRecordRevision,
|
||||
selectors: _SubjectSelectors,
|
||||
direct_case_ids: set[str],
|
||||
) -> bool:
|
||||
if selectors.revision_id:
|
||||
return row.id == selectors.revision_id
|
||||
return bool(selectors.case_id and row.case_id in direct_case_ids)
|
||||
|
||||
|
||||
def _grant_is_direct(
|
||||
row: CaseAccessGrant,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> bool:
|
||||
return bool(selectors.access_grant_id and row.id == selectors.access_grant_id)
|
||||
|
||||
|
||||
def _timeline_is_direct(
|
||||
row: CaseTimelineEntry,
|
||||
selectors: _SubjectSelectors,
|
||||
direct_case_ids: set[str],
|
||||
) -> bool:
|
||||
if selectors.timeline_id:
|
||||
return row.id == selectors.timeline_id
|
||||
return bool(selectors.case_id and row.case_id in direct_case_ids)
|
||||
|
||||
|
||||
def _grant_subject_matches(
|
||||
row: CaseAccessGrant,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> bool:
|
||||
return bool(
|
||||
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
|
||||
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
|
||||
)
|
||||
|
||||
|
||||
def _case_identity_record(row: CaseIdentity) -> DsarRecordRef:
|
||||
return _record(
|
||||
"cases_case_identity",
|
||||
row.id,
|
||||
"case_identity",
|
||||
"Case identity",
|
||||
{
|
||||
"match_fields": ["reference"],
|
||||
"case_id": row.case_id,
|
||||
"case_number": _bounded_text(row.case_number, 255),
|
||||
"created_at": _iso(row.created_at),
|
||||
},
|
||||
observed_at=row.created_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"The stable case identifier and number are retained so immutable case and "
|
||||
"record evidence remains reconstructable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _revision_record(
|
||||
row: CaseRecordRevision,
|
||||
record: CaseRecord,
|
||||
) -> DsarRecordRef:
|
||||
return _record(
|
||||
"cases_case_revision",
|
||||
row.id,
|
||||
"case_history",
|
||||
"Case revision",
|
||||
{
|
||||
"match_fields": ["reference"],
|
||||
"case_id": row.case_id,
|
||||
"case_number": _bounded_text(record.case_number, 255),
|
||||
"revision": row.revision,
|
||||
"previous_revision_id": row.previous_revision_id,
|
||||
"case_type_key": row.case_type_key,
|
||||
"status_key": row.status_key,
|
||||
"title": _bounded_text(row.title, 500),
|
||||
"access_mode": row.access_mode,
|
||||
"opened_at": _iso(row.opened_at),
|
||||
"deadline_at": _iso(row.deadline_at),
|
||||
"closed_at": _iso(row.closed_at),
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"service_ref": _institutional_reference(record.service_ref),
|
||||
"party_reference_count": len(record.party_refs),
|
||||
"assignment_reference_count": len(record.assignment_refs),
|
||||
"evidence_reference_count": len(record.evidence_refs),
|
||||
"decision_reference_count": len(record.decision_refs),
|
||||
"record_reference_count": len(record.record_refs),
|
||||
"access_grant_count": len(record.access_grants),
|
||||
},
|
||||
observed_at=row.recorded_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Case revisions are immutable procedure and accountability evidence; "
|
||||
"corrections append a new governed revision."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _current_fact_record(
|
||||
row: CaseRecordRevision,
|
||||
record: CaseRecord,
|
||||
) -> DsarRecordRef:
|
||||
return _record(
|
||||
"cases_current_case_fact",
|
||||
row.case_id,
|
||||
"current_case_fact",
|
||||
"Current case fact",
|
||||
{
|
||||
"match_fields": ["reference"],
|
||||
"revision_record_id": row.id,
|
||||
"revision": row.revision,
|
||||
"case_number": _bounded_text(record.case_number, 255),
|
||||
"status_key": row.status_key,
|
||||
"title": _bounded_text(row.title, 500),
|
||||
"deadline_at": _iso(row.deadline_at),
|
||||
},
|
||||
observed_at=row.recorded_at,
|
||||
)
|
||||
|
||||
|
||||
def _access_grant_record(row: CaseAccessGrant) -> DsarRecordRef:
|
||||
immutable = not row.active
|
||||
return _record(
|
||||
"cases_access_grant",
|
||||
row.id,
|
||||
"case_access_fact",
|
||||
"Case access grant",
|
||||
{
|
||||
"match_fields": ["subject"],
|
||||
"case_id": row.case_id,
|
||||
"subject_kind": row.subject_kind,
|
||||
"subject_id": row.subject_id,
|
||||
"permissions": [str(value)[:40] for value in row.permissions[:20]],
|
||||
"source": _bounded_text(row.source, 30),
|
||||
"active": row.active,
|
||||
"source_revision": row.source_revision,
|
||||
"created_at": _iso(row.created_at),
|
||||
"updated_at": _iso(row.updated_at),
|
||||
},
|
||||
observed_at=row.updated_at,
|
||||
immutable=immutable,
|
||||
retention_reason=(
|
||||
"Inactive access-grant state is retained to explain historical case access."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _timeline_record(
|
||||
row: CaseTimelineEntry,
|
||||
*,
|
||||
expose_actor: bool,
|
||||
match_fields: Sequence[str],
|
||||
) -> DsarRecordRef:
|
||||
return _record(
|
||||
"cases_timeline_event",
|
||||
row.id,
|
||||
"case_lifecycle_evidence",
|
||||
"Case lifecycle event",
|
||||
{
|
||||
"match_fields": list(match_fields),
|
||||
"case_id": row.case_id,
|
||||
"event_type": _bounded_text(row.event_type, 120),
|
||||
"case_revision": row.case_revision,
|
||||
"occurred_at": _iso(row.occurred_at),
|
||||
"actor_id": row.actor_id if expose_actor else None,
|
||||
},
|
||||
observed_at=row.occurred_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Case timeline events are immutable lifecycle and accountability evidence."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _operator_record(
|
||||
*,
|
||||
resource_id: str,
|
||||
case_id: str,
|
||||
activity: str,
|
||||
observed_at: datetime | None,
|
||||
case_revision: int | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return _record(
|
||||
"cases_operator_attribution",
|
||||
resource_id,
|
||||
"operator_accountability_evidence",
|
||||
"Case operator attribution",
|
||||
{
|
||||
"match_fields": ["actor_id"],
|
||||
"case_id": case_id,
|
||||
"activity": activity,
|
||||
"case_revision": case_revision,
|
||||
"observed_at": _iso(observed_at),
|
||||
},
|
||||
observed_at=observed_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Operator attribution is retained as accountability evidence; raw case, "
|
||||
"party, evidence, change-reason, and event payload content is excluded."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
groups = {
|
||||
"account_id": (
|
||||
subject.account_id,
|
||||
subject.external_references.get("cases.account"),
|
||||
subject.external_references.get("access.account"),
|
||||
),
|
||||
"identity_id": (
|
||||
subject.identity_id,
|
||||
subject.external_references.get("cases.identity"),
|
||||
subject.external_references.get("identity.id"),
|
||||
),
|
||||
"membership_id": (
|
||||
subject.membership_id,
|
||||
subject.external_references.get("cases.membership"),
|
||||
subject.external_references.get("tenancy.membership"),
|
||||
),
|
||||
"case_id": (subject.external_references.get("cases.case"),),
|
||||
"revision_id": (subject.external_references.get("cases.revision"),),
|
||||
"access_grant_id": (subject.external_references.get("cases.access_grant"),),
|
||||
"timeline_id": (subject.external_references.get("cases.timeline"),),
|
||||
}
|
||||
normalized: dict[str, str | None] = {}
|
||||
for key, values in groups.items():
|
||||
distinct = {value for item in values if (value := _normalized_id(item))}
|
||||
if len(distinct) > 1:
|
||||
return None
|
||||
normalized[key] = next(iter(distinct), None)
|
||||
return _SubjectSelectors(**normalized)
|
||||
|
||||
|
||||
def _query_conditions(
|
||||
session: Session,
|
||||
model: type,
|
||||
tenant_id: str,
|
||||
conditions: Sequence[object],
|
||||
) -> list[object]:
|
||||
if not conditions:
|
||||
return []
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(model.id.asc())
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError("Cases DSAR match limit exceeded; narrow the selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _institutional_reference(value: object | None) -> dict[str, object] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return {
|
||||
"kind": str(getattr(value, "kind", "")),
|
||||
"owner_module": str(getattr(value, "owner_module", "")),
|
||||
"object_id": str(getattr(value, "object_id", "")),
|
||||
"version": _bounded_text(getattr(value, "version", None), 120),
|
||||
}
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "cases" or record.module_id != "cases":
|
||||
raise ValueError("Cases DSAR received a foreign provider record.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "cases" or action.module_id != "cases":
|
||||
raise ValueError("Cases DSAR received a foreign provider action.")
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="cases",
|
||||
module_id="cases",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path="/cases",
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Cases DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(value: str | None, limit: int) -> str | None:
|
||||
return value[:limit] if value else None
|
||||
|
||||
|
||||
def _normalized_id(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["CASES_DSAR_CAPABILITY", "CasesDsarProvider"]
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import parse_qs, quote, urlsplit
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import EvidenceReference
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
|
||||
|
||||
|
||||
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
|
||||
_SUPPORTED_REFERENCES = {
|
||||
("files", "file-version"): "document",
|
||||
("mail", "message"): "event",
|
||||
("campaigns", "campaign"): "event",
|
||||
}
|
||||
|
||||
|
||||
def link_case_evidence(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
expected_revision: int,
|
||||
reference: Mapping[str, object],
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
|
||||
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
|
||||
raise CaseStoreError("The owner reference is already linked to this Case.")
|
||||
return update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
changes={"evidence_refs": (*base.evidence_refs, evidence)},
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
def unlink_case_evidence(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
evidence_id: str,
|
||||
expected_revision: int,
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
evidence = next(
|
||||
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
|
||||
None,
|
||||
)
|
||||
if evidence is None or linked_source(evidence) is None:
|
||||
raise CaseStoreError("The linked owner reference was not found.")
|
||||
return update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
changes={
|
||||
"evidence_refs": tuple(
|
||||
item for item in base.evidence_refs if item.evidence_id != evidence_id
|
||||
)
|
||||
},
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
|
||||
if evidence.source_ref is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(evidence.source_ref)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
|
||||
return None
|
||||
result = {
|
||||
key: str(value.get(key) or "")
|
||||
for key in ("owner_module", "kind", "object_id", "path")
|
||||
}
|
||||
if (
|
||||
not all(result.values())
|
||||
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
|
||||
):
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _base_revision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
expected_revision: int,
|
||||
) -> CaseRecord:
|
||||
base = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=expected_revision,
|
||||
)
|
||||
if base is None:
|
||||
raise LookupError("Case revision not found.")
|
||||
return base
|
||||
|
||||
|
||||
def _validated_evidence(
|
||||
value: Mapping[str, object],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> EvidenceReference:
|
||||
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
|
||||
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
|
||||
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
|
||||
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
|
||||
version = _bounded(value.get("version"), "Owner version", 255)
|
||||
path = _bounded(value.get("path"), "Owner path", 2_000)
|
||||
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
|
||||
if evidence_kind is None:
|
||||
raise CaseStoreError("The owner reference kind is not supported by Cases.")
|
||||
if reference_tenant != tenant_id:
|
||||
raise CaseStoreError("Owner references cannot cross Case tenants.")
|
||||
_validate_owner_path(
|
||||
owner_module=owner_module,
|
||||
reference_kind=reference_kind,
|
||||
object_id=object_id,
|
||||
version=version,
|
||||
path=path,
|
||||
)
|
||||
canonical = {
|
||||
"contract": _SOURCE_CONTRACT,
|
||||
"owner_module": owner_module,
|
||||
"kind": reference_kind,
|
||||
"object_id": object_id,
|
||||
"path": path,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonical,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
digest = hashlib.sha256(
|
||||
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
|
||||
).hexdigest()
|
||||
return EvidenceReference(
|
||||
kind=evidence_kind, # type: ignore[arg-type]
|
||||
owner_module=owner_module,
|
||||
evidence_id=f"linked-{digest[:40]}",
|
||||
tenant_id=tenant_id,
|
||||
version=version,
|
||||
source_ref=encoded,
|
||||
derived_from=(_SOURCE_CONTRACT,),
|
||||
)
|
||||
|
||||
|
||||
def _validate_owner_path(
|
||||
*,
|
||||
owner_module: str,
|
||||
reference_kind: str,
|
||||
object_id: str,
|
||||
version: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
parsed = urlsplit(path)
|
||||
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
|
||||
raise CaseStoreError("Owner paths must be local platform routes.")
|
||||
if (owner_module, reference_kind) == ("files", "file-version"):
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if (
|
||||
parsed.path != "/files"
|
||||
or query.get("versionId") != [object_id]
|
||||
or version != object_id
|
||||
or not query.get("fileId", [""])[0]
|
||||
):
|
||||
raise CaseStoreError("Files links require an exact authorized version path.")
|
||||
return
|
||||
if (owner_module, reference_kind) == ("mail", "message"):
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
profile = query.get("profile", [""])[0]
|
||||
folder = query.get("folder", [""])[0]
|
||||
message = query.get("message", [""])[0]
|
||||
expected_id = f"{profile}:{folder}:{message}"
|
||||
if (
|
||||
parsed.path != "/mail"
|
||||
or not profile
|
||||
or not folder
|
||||
or not message
|
||||
or object_id != expected_id
|
||||
or version != message
|
||||
):
|
||||
raise CaseStoreError("Mail links require an exact mailbox message path.")
|
||||
return
|
||||
expected_path = f"/campaigns/{quote(object_id, safe='')}"
|
||||
if path != expected_path:
|
||||
raise CaseStoreError("Campaign links require an exact Campaign path.")
|
||||
|
||||
|
||||
def _bounded(value: object, label: str, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise CaseStoreError(f"{label} is required.")
|
||||
if len(result) > maximum or any(ord(character) < 32 for character in result):
|
||||
raise CaseStoreError(f"{label} is invalid.")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"link_case_evidence",
|
||||
"linked_source",
|
||||
"unlink_case_evidence",
|
||||
]
|
||||
@@ -6,9 +6,15 @@ from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -20,9 +26,12 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
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,
|
||||
@@ -34,6 +43,7 @@ from govoplan_cases.backend.party_context import (
|
||||
)
|
||||
from govoplan_cases.backend.acl import CaseAclProvider
|
||||
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.service_intake import (
|
||||
CAPABILITY_CASES_SERVICE_INTAKE,
|
||||
CaseServiceIntake,
|
||||
@@ -46,11 +56,17 @@ from govoplan_cases.backend.service import (
|
||||
CAPABILITY_CASES_REGISTRY,
|
||||
SqlCaseRegistry,
|
||||
)
|
||||
from govoplan_cases.backend.record_source import (
|
||||
CAPABILITY_RECORD_SOURCE_CASES,
|
||||
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.16"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
READ_SCOPE = "cases:case:read"
|
||||
CREATE_SCOPE = "cases:case:create"
|
||||
UPDATE_SCOPE = "cases:case:update"
|
||||
@@ -79,6 +95,15 @@ def _service_launcher(context: ModuleContext) -> CaseServiceLauncher:
|
||||
return CaseServiceLauncher()
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
|
||||
del context
|
||||
return CasesDsarProvider()
|
||||
|
||||
|
||||
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
|
||||
return TicketCaseEscalationProvider()
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_cases.backend.router import router
|
||||
@@ -131,8 +156,18 @@ manifest = ModuleManifest(
|
||||
"decisions",
|
||||
"forms_runtime",
|
||||
"workflow_engine",
|
||||
"records",
|
||||
"files",
|
||||
"mail",
|
||||
"campaigns",
|
||||
"quick_access",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
),
|
||||
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
||||
permissions=(
|
||||
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
|
||||
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
|
||||
@@ -198,6 +233,17 @@ manifest = ModuleManifest(
|
||||
order=35,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="services-cases",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.services_cases",
|
||||
icon="landmark",
|
||||
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="cases.navigation",
|
||||
@@ -276,6 +322,14 @@ manifest = ModuleManifest(
|
||||
parent_id="cases.detail",
|
||||
order=60,
|
||||
),
|
||||
ViewSurface(
|
||||
id="cases.detail.decision",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Prepare formal decision",
|
||||
parent_id="cases.detail",
|
||||
order=70,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
@@ -283,17 +337,25 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="cases.registry", 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=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),
|
||||
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_CASES_SERVICE_INTAKE: _service_intake,
|
||||
CAPABILITY_CASES_PARTY_CONTEXT: _party_context,
|
||||
CAPABILITY_CASES_REGISTRY: _case_registry,
|
||||
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(
|
||||
@@ -316,6 +378,21 @@ manifest = ModuleManifest(
|
||||
summary="Starts a replay-safe case from an exact available Service revision.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation(
|
||||
label="Cases record source",
|
||||
summary="Resolves currently authorized immutable case revisions for Records filing.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CASES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Cases data-subject request provider",
|
||||
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,
|
||||
@@ -345,8 +422,248 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
resource_acl_providers=(CaseAclProvider(),),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="cases.cases",
|
||||
factory=create_cases_search_source,
|
||||
),
|
||||
),
|
||||
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",
|
||||
summary=(
|
||||
"Append or remove an owner-authorized reference without copying "
|
||||
"the provider's content into Cases."
|
||||
),
|
||||
body=(
|
||||
"On an active Case, Files, Mail, and Campaigns may return an exact "
|
||||
"tenant-bound selection through the versioned Quick Access result "
|
||||
"contract. Cases accepts only the supported owner and reference-kind "
|
||||
"pairs, a matching tenant, a pinned version, and the owner's canonical "
|
||||
"local route. Linking and unlinking recheck Case update authority, use "
|
||||
"expected-revision concurrency and idempotency, and append immutable "
|
||||
"Case history. Cases stores only a typed owner identifier, exact version, "
|
||||
"and owner route; it never copies file bytes, message headers or bodies, "
|
||||
"recipients, campaign content, or a source access decision. Opening a "
|
||||
"link always enters the owning module, which rechecks current access. "
|
||||
"If the owner module is disabled or source access is later revoked, the "
|
||||
"Case retains the historical reference but does not disclose source "
|
||||
"content. Unlinking removes only the reference from the next Case revision "
|
||||
"and never deletes the owner object. Unsaved Case edits must be saved or "
|
||||
"discarded before a Quick Access result can be linked."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "operator", "module_admin", "auditor"),
|
||||
related_modules=("quick_access", "files", "mail", "campaigns"),
|
||||
order=12,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases",),
|
||||
required_scopes=(UPDATE_SCOPE,),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Quick Access architecture",
|
||||
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.detail.references"],
|
||||
"consequence_classes": {
|
||||
"link_owner_reference": "Appends an immutable Case revision containing only the exact typed owner reference.",
|
||||
"unlink_owner_reference": "Appends an immutable Case revision and leaves the owner object unchanged.",
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.data-subject-requests",
|
||||
title="Case data-subject requests",
|
||||
summary="Report governed case facts without guessing applicant identity or exposing opaque case payloads.",
|
||||
body=(
|
||||
"The Cases DSAR provider matches exact-tenant account, identity, and membership attribution, account/identity access grants, and explicit Cases references. "
|
||||
"A canonical selector combined with a direct reference must be corroborated by a Cases-owned relationship or the search fails closed. Direct case and revision references export a typed, minimized lifecycle projection; raw snapshots, metadata, search text, free-text change reasons, timeline payloads and summaries, evidence identifiers, request digests, idempotency keys, audit identifiers, and unrelated access subjects are excluded. "
|
||||
"Immutable case identities, revisions, timelines, and attribution are retained. Current open case and active access facts require authorized manual review through the normal case/access lifecycle; no automatic erasure is published. Applicant-to-identity linkage remains owned by Parties, so Cases does not infer it from party identifiers."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("parties", "records", "forms_runtime", "portal"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Cases concept",
|
||||
href="govoplan-cases/docs/CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.quick-access-context",
|
||||
title="Use task-local tools from an active Case",
|
||||
summary="Launch an authorized full-page tool and return to the exact Case route and context.",
|
||||
body=(
|
||||
"After an exact Case revision loads, the Case detail page publishes a bounded active-object reference to Core. "
|
||||
"Quick Access may carry that reference together with the current tenant, acting assignment, temporal selection, "
|
||||
"View revision, and route when opening an owning module's full page. It never carries Case content or an access "
|
||||
"decision. The destination reauthorizes its data and effects. Unsaved Case edits use the shared save/discard guard "
|
||||
"before navigation, and the shell provides an explicit return action."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "operator", "module_admin"),
|
||||
related_modules=("quick_access", "views", "access"),
|
||||
order=13,
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Quick Access architecture",
|
||||
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Aufgabenbezogene Werkzeuge aus einem aktiven Vorgang verwenden",
|
||||
"summary": "Ein berechtigtes Werkzeug oeffnen und zum genauen Vorgangskontext zurueckkehren.",
|
||||
"body": (
|
||||
"Nach dem Laden einer genauen Vorgangsrevision veroeffentlicht die Detailseite einen begrenzten Objektverweis an Core. "
|
||||
"Der Schnellzugriff darf diesen Verweis zusammen mit Mandant, handelnder Zuordnung, Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. "
|
||||
"Vorgangsinhalte oder Zugriffsentscheidungen werden nicht uebertragen. Das Ziel prueft Daten und Wirkungen erneut; ungespeicherte Aenderungen "
|
||||
"werden vor dem Wechsel durch den gemeinsamen Speichern-Verwerfen-Dialog geschuetzt."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"help_contexts": ["cases.detail.quick-access"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.record-formal-decision",
|
||||
title="Record a formal Decision from a Case",
|
||||
summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.",
|
||||
body=(
|
||||
"The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. "
|
||||
"Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through "
|
||||
"Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then "
|
||||
"records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision "
|
||||
"in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "decision_officer", "operator", "auditor"),
|
||||
related_modules=("access", "mandates", "decisions", "records"),
|
||||
order=14,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases", "access", "mandates", "decisions"),
|
||||
required_scopes=("cases:case:update", "decisions:decision:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
||||
"prerequisites": [
|
||||
"The Case has an exact responsible organization, function, evidence, and legal-basis version.",
|
||||
"An active acting assignment and exactly one effective Mandate authorize the Decision type.",
|
||||
],
|
||||
"steps": [
|
||||
"Open the Case and select the appropriate acting assignment in the title bar.",
|
||||
"Record the operative result, reasoning, optional conditions, effective time, and change reason.",
|
||||
"Confirm the exact Decision and Case revisions in the Case timeline.",
|
||||
"When Records is enabled, file the exact Decision or Case revision into the destination eAkte.",
|
||||
],
|
||||
"outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.file-exact-revision",
|
||||
title="File an exact case revision into an eAkte",
|
||||
summary="Preserve a reconstructable case snapshot in Records without moving case ownership.",
|
||||
body=(
|
||||
"When Records is enabled, Cases resolves an exact immutable case revision only after "
|
||||
"current case permission and object-level access are checked. The filed reference includes "
|
||||
"the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 "
|
||||
"digest of the canonical snapshot. Cases remains authoritative for the case lifecycle."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "records_manager", "operator", "auditor"),
|
||||
related_modules=("records",),
|
||||
order=15,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases", "records"),
|
||||
required_scopes=("cases:case:read", "records:workspace:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(label="Records", href="/records", kind="runtime"),
|
||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.detail", "records.action.file"],
|
||||
"prerequisites": [
|
||||
"You currently may read the case and file items into the destination record.",
|
||||
"The exact immutable case revision has been identified.",
|
||||
],
|
||||
"steps": [
|
||||
"Open the case and identify the revision that represents the evidence state.",
|
||||
"Choose the destination record and state the access purpose and filing reason.",
|
||||
"Confirm filing; Cases rechecks current access and resolves the exact revision.",
|
||||
"Verify the case revision and snapshot digest in the record chronology.",
|
||||
],
|
||||
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.institutional-context",
|
||||
title="Case institutional context",
|
||||
@@ -357,7 +674,10 @@ manifest = ModuleManifest(
|
||||
"intake retains the exact Service, Mandate, jurisdiction, legal basis, form, "
|
||||
"workflow, and result bindings. Procedure parties come from an optional provider "
|
||||
"or a limited Cases-only compatibility projection. Assignment, evidence, Decision, "
|
||||
"and record links remain stable references owned by their source modules."
|
||||
"and record links remain stable references owned by their source modules. When "
|
||||
"Search is enabled, Cases contributes a rebuildable metadata-only projection. "
|
||||
"Evidence and protected Decision content are excluded, and every candidate is "
|
||||
"rechecked against current Case access before disclosure."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -373,6 +693,7 @@ manifest = ModuleManifest(
|
||||
"help_contexts": [
|
||||
"cases.list",
|
||||
"cases.detail",
|
||||
"cases.search.result",
|
||||
"cases.state.read-only",
|
||||
"cases.state.restricted",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordContractError,
|
||||
RecordSourceLocator,
|
||||
RecordSourceReference,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||
from govoplan_cases.backend.service import can_access_case
|
||||
|
||||
|
||||
CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases"
|
||||
|
||||
|
||||
class CasesRecordSource:
|
||||
provider_id = "cases"
|
||||
|
||||
def resource_types(self) -> Sequence[str]:
|
||||
return ("case_revision",)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
locator: RecordSourceLocator,
|
||||
purpose: str,
|
||||
) -> RecordSourceReference:
|
||||
if not isinstance(session, Session):
|
||||
raise RecordContractError(
|
||||
"Case record references require a database session."
|
||||
)
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id or locator.tenant_id != tenant_id:
|
||||
raise RecordContractError("Case record references cannot cross tenants.")
|
||||
if locator.source_module != "cases" or locator.resource_type != "case_revision":
|
||||
raise RecordContractError("Unsupported Cases record source type.")
|
||||
if not str(purpose or "").strip():
|
||||
raise RecordContractError("Case record references require a purpose.")
|
||||
if not hasattr(principal, "has") or not (
|
||||
principal.has("cases:case:read") or principal.has("cases:case:admin")
|
||||
):
|
||||
raise RecordContractError("Current Cases read permission is required.")
|
||||
if not can_access_case(
|
||||
session, principal, case_id=locator.resource_id, permission="read"
|
||||
):
|
||||
raise RecordContractError("The current principal cannot read this case.")
|
||||
try:
|
||||
revision = int(locator.source_revision)
|
||||
except ValueError as exc:
|
||||
raise RecordContractError("Case source revisions must be numeric.") from exc
|
||||
row = (
|
||||
session.query(CaseRecordRevision)
|
||||
.filter(
|
||||
CaseRecordRevision.tenant_id == tenant_id,
|
||||
CaseRecordRevision.case_id == locator.resource_id,
|
||||
CaseRecordRevision.revision == revision,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise RecordContractError("The exact case revision does not exist.")
|
||||
identity = (
|
||||
session.query(CaseIdentity)
|
||||
.filter(
|
||||
CaseIdentity.tenant_id == tenant_id,
|
||||
CaseIdentity.case_id == locator.resource_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if identity is None:
|
||||
raise RecordContractError("The case identity is unavailable.")
|
||||
snapshot_json = json.dumps(
|
||||
row.snapshot,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return RecordSourceReference(
|
||||
locator=locator,
|
||||
label=f"{identity.case_number} - {row.title}",
|
||||
authority_mode="external_authoritative",
|
||||
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
|
||||
content_type="application/vnd.govoplan.case-revision+json",
|
||||
size_bytes=len(snapshot_json),
|
||||
valid_from=row.opened_at,
|
||||
valid_to=row.closed_at,
|
||||
recorded_at=row.recorded_at,
|
||||
launch_url=f"/cases/{quote(row.case_id, safe='')}",
|
||||
metadata={
|
||||
"case_number": identity.case_number,
|
||||
"case_type_key": row.case_type_key,
|
||||
"status_key": row.status_key,
|
||||
"access_mode": row.access_mode,
|
||||
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_cases_record_source(_context: object) -> CasesRecordSource:
|
||||
return CasesRecordSource()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_RECORD_SOURCE_CASES",
|
||||
"CasesRecordSource",
|
||||
"create_cases_record_source",
|
||||
]
|
||||
@@ -32,6 +32,9 @@ from govoplan_cases.backend.manifest import (
|
||||
UPDATE_SCOPE,
|
||||
)
|
||||
from govoplan_cases.backend.schemas import (
|
||||
CaseDecisionRequest,
|
||||
CaseEvidenceLinkRequest,
|
||||
CaseEvidenceUnlinkRequest,
|
||||
CaseHistoryResponse,
|
||||
CaseListResponse,
|
||||
CaseStatusWriteRequest,
|
||||
@@ -40,6 +43,19 @@ from govoplan_cases.backend.schemas import (
|
||||
CaseUpdateRequest,
|
||||
CaseWriteRequest,
|
||||
)
|
||||
from govoplan_cases.backend.evidence_links import (
|
||||
link_case_evidence,
|
||||
unlink_case_evidence,
|
||||
)
|
||||
from govoplan_cases.backend.decision_path import (
|
||||
CaseDecisionCommand,
|
||||
CaseDecisionError,
|
||||
CaseDecisionPath,
|
||||
CaseDecisionUnavailable,
|
||||
DECISION_READ_SCOPE,
|
||||
DECISION_SENSITIVE_READ_SCOPE,
|
||||
DECISION_WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
CaseStoreError,
|
||||
can_access_case,
|
||||
@@ -66,7 +82,9 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
lowered = message.casefold()
|
||||
if isinstance(exc, LookupError):
|
||||
if isinstance(exc, CaseDecisionUnavailable):
|
||||
code = status.HTTP_424_FAILED_DEPENDENCY
|
||||
elif isinstance(exc, LookupError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
@@ -206,6 +224,78 @@ def api_create_case(
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
||||
def api_case_decisions(
|
||||
case_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
_require(principal, READ_SCOPE)
|
||||
_require(principal, DECISION_READ_SCOPE)
|
||||
try:
|
||||
items = CaseDecisionPath(get_registry()).linked(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
)
|
||||
except (
|
||||
CaseDecisionError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
raise _error(exc) from exc
|
||||
disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE)
|
||||
return {
|
||||
"decisions": [
|
||||
item.to_dict(include_protected=disclose)
|
||||
for item in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{case_id}/decisions",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_record_case_decision(
|
||||
case_id: str,
|
||||
payload: CaseDecisionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
_require(principal, DECISION_WRITE_SCOPE)
|
||||
try:
|
||||
result = CaseDecisionPath(get_registry()).record(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
command=CaseDecisionCommand(
|
||||
expected_case_revision=payload.expected_case_revision,
|
||||
effective_at=payload.effective_at,
|
||||
decision_type=payload.decision_type,
|
||||
operative_result=payload.operative_result,
|
||||
reasoning=payload.reasoning,
|
||||
conditions=tuple(payload.conditions),
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseDecisionError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/{case_id}", response_model=dict[str, Any])
|
||||
def api_get_case(
|
||||
case_id: str,
|
||||
@@ -265,6 +355,72 @@ def api_update_case(
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.post("/{case_id}/evidence-links", response_model=dict[str, Any])
|
||||
def api_link_case_evidence(
|
||||
case_id: str,
|
||||
payload: CaseEvidenceLinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
try:
|
||||
item = link_case_evidence(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
reference=payload.reference.model_dump(),
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseStoreError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{case_id}/evidence-links/{evidence_id}/unlink",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_unlink_case_evidence(
|
||||
case_id: str,
|
||||
evidence_id: str,
|
||||
payload: CaseEvidenceUnlinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
try:
|
||||
item = unlink_case_evidence(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseStoreError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{case_id}/share-target-options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
|
||||
@@ -82,6 +82,49 @@ class CaseUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CaseOwnerReferenceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
owner_module: str = Field(min_length=1, max_length=120)
|
||||
kind: str = Field(min_length=1, max_length=120)
|
||||
object_id: str = Field(min_length=1, max_length=1_000)
|
||||
tenant_id: str = Field(min_length=1, max_length=255)
|
||||
version: str = Field(min_length=1, max_length=255)
|
||||
path: str = Field(min_length=1, max_length=2_000)
|
||||
|
||||
|
||||
class CaseEvidenceLinkRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
reference: CaseOwnerReferenceRequest
|
||||
|
||||
|
||||
class CaseEvidenceUnlinkRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CaseDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_case_revision: int = Field(ge=1)
|
||||
effective_at: datetime
|
||||
decision_type: str = Field(min_length=1, max_length=120)
|
||||
operative_result: str = Field(min_length=1, max_length=20_000)
|
||||
reasoning: str = Field(min_length=1, max_length=50_000)
|
||||
conditions: list[str] = Field(default_factory=list, max_length=100)
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
cases: list[dict[str, Any]]
|
||||
total: int
|
||||
@@ -99,6 +142,9 @@ class CaseTimelineResponse(BaseModel):
|
||||
|
||||
__all__ = [
|
||||
"CaseGrantRequest",
|
||||
"CaseDecisionRequest",
|
||||
"CaseEvidenceLinkRequest",
|
||||
"CaseEvidenceUnlinkRequest",
|
||||
"CaseHistoryResponse",
|
||||
"CaseListResponse",
|
||||
"CaseStatusWriteRequest",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
)
|
||||
from govoplan_cases.backend.service import can_access_case
|
||||
|
||||
|
||||
PROVIDER_ID = "cases.cases"
|
||||
RESOURCE_TYPE = "case"
|
||||
READ_SCOPE = "cases:case:read"
|
||||
ADMIN_SCOPE = "cases:case:admin"
|
||||
|
||||
|
||||
class CasesSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="cases",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Cases",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = (
|
||||
select(CaseRecordRevision, CaseIdentity)
|
||||
.join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id)
|
||||
.where(
|
||||
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(CaseRecordRevision.case_id > request.cursor)
|
||||
rows = list(
|
||||
db.execute(
|
||||
statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1)
|
||||
).all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
grants = _grants_by_case(
|
||||
db,
|
||||
request.tenant_id,
|
||||
[row.case_id for row, _identity in selected],
|
||||
)
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(CaseRecordRevision.recorded_at)).where(
|
||||
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(
|
||||
row,
|
||||
identity=identity,
|
||||
grants=grants.get(row.case_id, ()),
|
||||
)
|
||||
for row, identity in selected
|
||||
),
|
||||
next_cursor=selected[-1][0].case_id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "cases"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = can_access_case(
|
||||
db,
|
||||
principal,
|
||||
case_id=reference.resource_id,
|
||||
permission="read",
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "cases"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type != RESOURCE_TYPE
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
row = db.scalar(
|
||||
select(CaseRecordRevision).where(
|
||||
CaseRecordRevision.tenant_id == event.tenant.id,
|
||||
CaseRecordRevision.case_id == event.resource.id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
identity = db.scalar(
|
||||
select(CaseIdentity).where(
|
||||
CaseIdentity.tenant_id == event.tenant.id,
|
||||
CaseIdentity.case_id == event.resource.id,
|
||||
)
|
||||
)
|
||||
deleted = row is None or identity is None
|
||||
cursor = event.event_id
|
||||
document = None
|
||||
if not deleted:
|
||||
document = _document(
|
||||
row,
|
||||
identity=identity,
|
||||
grants=_grants_by_case(
|
||||
db,
|
||||
event.tenant.id,
|
||||
[row.case_id],
|
||||
).get(row.case_id, ()),
|
||||
change_cursor=cursor,
|
||||
)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="cases",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="delete" if deleted else "upsert",
|
||||
reference=reference,
|
||||
source_revision=(document.source_revision if document else cursor),
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource:
|
||||
return CasesSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: CaseRecordRevision,
|
||||
*,
|
||||
identity: CaseIdentity,
|
||||
grants: Sequence[CaseAccessGrant],
|
||||
change_cursor: str | None = None,
|
||||
) -> SearchDocument:
|
||||
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
|
||||
if identity.created_by:
|
||||
tokens.extend(
|
||||
(f"account:{identity.created_by}", f"membership:{identity.created_by}")
|
||||
)
|
||||
prefixes = {
|
||||
"account": "account",
|
||||
"identity": "identity",
|
||||
"group": "group",
|
||||
"role": "role",
|
||||
"function": "function",
|
||||
"function_assignment": "function",
|
||||
}
|
||||
for grant in grants:
|
||||
prefix = prefixes.get(grant.subject_kind)
|
||||
if prefix:
|
||||
tokens.append(f"{prefix}:{grant.subject_id}")
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="cases",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.case_id,
|
||||
title=row.title,
|
||||
url=f"/cases/{quote(row.case_id, safe='')}",
|
||||
summary=f"{identity.case_number} - {row.status_key}",
|
||||
body=row.search_text[:200_000],
|
||||
keywords=(
|
||||
identity.case_number[:200],
|
||||
row.case_type_key[:200],
|
||||
row.status_key[:200],
|
||||
),
|
||||
visibility="restricted",
|
||||
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||
metadata={
|
||||
"case_number": identity.case_number,
|
||||
"case_type_key": row.case_type_key,
|
||||
"status_key": row.status_key,
|
||||
"access_mode": row.access_mode,
|
||||
},
|
||||
source_revision=str(row.revision),
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=row.recorded_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _grants_by_case(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
case_ids: Sequence[str],
|
||||
) -> dict[str, tuple[CaseAccessGrant, ...]]:
|
||||
grouped: dict[str, list[CaseAccessGrant]] = {
|
||||
case_id: [] for case_id in case_ids
|
||||
}
|
||||
if not case_ids:
|
||||
return {}
|
||||
rows = session.scalars(
|
||||
select(CaseAccessGrant).where(
|
||||
CaseAccessGrant.tenant_id == tenant_id,
|
||||
CaseAccessGrant.case_id.in_(tuple(case_ids)),
|
||||
CaseAccessGrant.active.is_(True),
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
grouped.setdefault(row.case_id, []).append(row)
|
||||
return {key: tuple(value) for key, value in grouped.items()}
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Cases search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Cases search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CasesSearchSource",
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"create_cases_search_source",
|
||||
]
|
||||
@@ -331,6 +331,8 @@ def create_case_from_intake(
|
||||
"result_refs": list(plan.result_refs),
|
||||
"required_evidence_types": list(plan.required_evidence_types),
|
||||
"deadline_refs": list(plan.deadline_refs),
|
||||
"remedy_refs": list(plan.remedy_refs),
|
||||
"service_level_refs": list(plan.service_level_refs),
|
||||
},
|
||||
)
|
||||
return create_case(
|
||||
|
||||
@@ -26,6 +26,8 @@ class CaseIntakePlan:
|
||||
result_refs: tuple[str, ...] = ()
|
||||
required_evidence_types: tuple[str, ...] = ()
|
||||
deadline_refs: tuple[str, ...] = ()
|
||||
remedy_refs: tuple[str, ...] = ()
|
||||
service_level_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class CaseServiceIntake:
|
||||
@@ -91,6 +93,8 @@ class CaseServiceIntake:
|
||||
result_refs=_binding_refs(definition, "result"),
|
||||
required_evidence_types=definition.required_evidence_types,
|
||||
deadline_refs=definition.deadline_refs,
|
||||
remedy_refs=definition.remedy_refs,
|
||||
service_level_refs=definition.service_level_refs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
FunctionAssignmentRef,
|
||||
)
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
EvidenceReference,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.decision_path import (
|
||||
CaseDecisionCommand,
|
||||
CaseDecisionError,
|
||||
CaseDecisionPath,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case,
|
||||
get_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 6, 9, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
identity_id: str = "identity-1"
|
||||
acting_assignment_id: str | None = "assignment-1"
|
||||
function_assignment_ids: tuple[str, ...] = ("assignment-1",)
|
||||
scopes: frozenset[str] = frozenset(
|
||||
{"cases:case:read", "cases:case:update", "decisions:decision:write"}
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, providers: dict[str, object]) -> None:
|
||||
self.providers = providers
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.providers
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.providers[name]
|
||||
|
||||
|
||||
class AccessDirectory:
|
||||
def get_function_assignment(self, assignment_id: str):
|
||||
if assignment_id != "assignment-1":
|
||||
return None
|
||||
return FunctionAssignmentRef(
|
||||
id=assignment_id,
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
function_id="permit-officer",
|
||||
organization_unit_id="permits",
|
||||
valid_from=NOW - timedelta(days=30),
|
||||
valid_until=NOW + timedelta(days=30),
|
||||
)
|
||||
|
||||
|
||||
class Mandates:
|
||||
def __init__(self, mandate: MandateDefinition) -> None:
|
||||
self.mandate = mandate
|
||||
|
||||
def resolve_mandate(self, session, principal, *, request):
|
||||
del session, principal
|
||||
return MandateResolution(competent=True, mandates=(self.mandate,))
|
||||
|
||||
|
||||
class Decisions:
|
||||
def __init__(self) -> None:
|
||||
self.items: dict[tuple[str, str], FormalDecision] = {}
|
||||
|
||||
def get_decision(self, session, principal, *, reference):
|
||||
del session, principal
|
||||
return self.items.get((reference.object_id, reference.version or ""))
|
||||
|
||||
def record_decision(self, session, principal, *, decision, expected_revision=None):
|
||||
del session, principal, expected_revision
|
||||
key = (decision.reference.object_id, decision.reference.version or "")
|
||||
existing = self.items.get(key)
|
||||
if existing is not None and existing != decision:
|
||||
raise ValueError("A different Decision payload already uses this revision.")
|
||||
self.items[key] = decision
|
||||
return decision
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str, *, version: str = "1") -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version=version,
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def legal_basis() -> LegalBasisReference:
|
||||
return LegalBasisReference(
|
||||
kind="law",
|
||||
authority="Example legislature",
|
||||
reference="permit-law:3",
|
||||
version="2026-01",
|
||||
)
|
||||
|
||||
|
||||
def evidence() -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind="document",
|
||||
owner_module="files",
|
||||
evidence_id="application-1",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def case_record() -> CaseRecord:
|
||||
case_ref = ref("case", "case-1", "cases")
|
||||
context = GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case intake.",
|
||||
),
|
||||
organization_unit_ref=ref("organization_unit", "permits", "organizations"),
|
||||
function_ref=ref("function", "permit-officer", "organizations"),
|
||||
mandate_ref=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
service_ref=ref("service", "permit", "services", version="4"),
|
||||
case_ref=case_ref,
|
||||
legal_bases=(legal_basis(),),
|
||||
)
|
||||
return CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number="PERMIT-2026-0001",
|
||||
case_type_key="permit-application",
|
||||
status_key="review",
|
||||
title="Permit application",
|
||||
context=context,
|
||||
service_ref=context.service_ref,
|
||||
party_refs=(ref("party", "applicant", "parties"),),
|
||||
assignment_refs=(ref("function_assignment", "assignment-1", "access"),),
|
||||
evidence_refs=(evidence(),),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Application received.",
|
||||
)
|
||||
|
||||
|
||||
def mandate() -> MandateDefinition:
|
||||
return MandateDefinition(
|
||||
reference=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||
temporal=TemporalRevision(
|
||||
revision="4",
|
||||
valid_from=NOW - timedelta(days=30),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=31),
|
||||
change_reason="Permit authority delegated.",
|
||||
),
|
||||
task_types=("cases.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
organization_unit_refs=(ref("organization_unit", "permits", "organizations"),),
|
||||
function_refs=(ref("function", "permit-officer", "organizations"),),
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
evidence=(evidence(),),
|
||||
)
|
||||
|
||||
|
||||
def command(
|
||||
*,
|
||||
key: str = "decision-1",
|
||||
result: str = "Permit granted.",
|
||||
expected_case_revision: int = 1,
|
||||
) -> CaseDecisionCommand:
|
||||
return CaseDecisionCommand(
|
||||
expected_case_revision=expected_case_revision,
|
||||
effective_at=NOW + timedelta(minutes=5),
|
||||
decision_type="permit",
|
||||
operative_result=result,
|
||||
reasoning="The submitted evidence satisfies the effective rule.",
|
||||
conditions=("Display the permit visibly.",),
|
||||
change_reason="Formal permit Decision recorded.",
|
||||
idempotency_key=key,
|
||||
)
|
||||
|
||||
|
||||
class CaseDecisionPathTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="review",
|
||||
label="Review",
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
self.principal,
|
||||
type_key="permit-application",
|
||||
label="Permit application",
|
||||
initial_status_key="review",
|
||||
allowed_status_keys=("review",),
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=case_record(),
|
||||
idempotency_key="case-1",
|
||||
)
|
||||
self.session.commit()
|
||||
self.decisions = Decisions()
|
||||
self.path = CaseDecisionPath(
|
||||
Registry(
|
||||
{
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: AccessDirectory(),
|
||||
CAPABILITY_MANDATE_RESOLVER: Mandates(mandate()),
|
||||
CAPABILITY_DECISION_REGISTRY: self.decisions,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_records_and_links_exact_decision_in_one_case_revision(self) -> None:
|
||||
result = self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(2, result.case.revision)
|
||||
self.assertEqual("Permit granted.", result.decision.operative_result)
|
||||
self.assertEqual("assignment-1", result.decision.authority_context.actor.function_assignment_ref.object_id)
|
||||
self.assertEqual("permit-mandate", result.decision.authority_context.mandate_ref.object_id)
|
||||
self.assertEqual(result.decision.reference, result.case.decision_refs[0])
|
||||
self.assertEqual(
|
||||
result.decision,
|
||||
self.path.linked(self.session, self.principal, case_id="case-1")[0],
|
||||
)
|
||||
|
||||
def test_unchanged_retry_replays_but_changed_payload_fails_closed(self) -> None:
|
||||
first = self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(),
|
||||
)
|
||||
self.session.commit()
|
||||
replay = self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(),
|
||||
)
|
||||
|
||||
self.assertTrue(replay.replayed)
|
||||
self.assertEqual(first.decision.reference, replay.decision.reference)
|
||||
with self.assertRaisesRegex(ValueError, "different Decision payload"):
|
||||
self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(result="Permit denied."),
|
||||
)
|
||||
|
||||
def test_stale_case_revision_and_missing_acting_assignment_fail_closed(self) -> None:
|
||||
self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(CaseDecisionError, "revision conflict"):
|
||||
self.path.record(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
command=command(key="another-decision"),
|
||||
)
|
||||
without_assignment = Principal(
|
||||
acting_assignment_id=None,
|
||||
function_assignment_ids=(),
|
||||
)
|
||||
current = get_case(self.session, self.principal, case_id="case-1")
|
||||
assert current is not None
|
||||
with self.assertRaisesRegex(PermissionError, "No active acting assignment"):
|
||||
self.path.record(
|
||||
self.session,
|
||||
without_assignment,
|
||||
case_id="case-1",
|
||||
command=command(
|
||||
key="third-decision",
|
||||
expected_case_revision=current.revision,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,471 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||
from govoplan_cases.backend.dsar_provider import (
|
||||
CASES_DSAR_CAPABILITY,
|
||||
CasesDsarProvider,
|
||||
)
|
||||
from govoplan_cases.backend.manifest import manifest
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case,
|
||||
update_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.core.institutional import (
|
||||
EvidenceReference,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Principal:
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: CasesDsarProvider,
|
||||
*,
|
||||
cases_active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.cases_active = cases_active
|
||||
|
||||
def capability_names(self):
|
||||
return (CASES_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "cases"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
cases_active = self.cases_active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("cases",) if cases_active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "cases"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != CASES_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
def _ref(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
owner: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version: str | None = "1",
|
||||
) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id=tenant_id,
|
||||
version=version,
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
case_id: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
title: str = "Subject permit",
|
||||
grants: tuple[CaseGrant, ...] = (),
|
||||
) -> CaseRecord:
|
||||
case_ref = _ref("case", case_id, "cases", tenant_id=tenant_id)
|
||||
service_ref = _ref("service", "permit", "services", tenant_id=tenant_id)
|
||||
return CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number=f"PERMIT-{case_id}",
|
||||
case_type_key="permit",
|
||||
status_key="open",
|
||||
title=title,
|
||||
access_mode="restricted" if grants else "tenant",
|
||||
access_grants=grants,
|
||||
context=GovernedContextEnvelope(
|
||||
tenant_id=tenant_id,
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="private-context-reason-do-not-export",
|
||||
),
|
||||
service_ref=service_ref,
|
||||
case_ref=case_ref,
|
||||
),
|
||||
service_ref=service_ref,
|
||||
party_refs=(
|
||||
_ref(
|
||||
"party",
|
||||
"private-party-reference-do-not-export",
|
||||
"parties",
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
),
|
||||
evidence_refs=(
|
||||
EvidenceReference(
|
||||
kind="document",
|
||||
owner_module="files",
|
||||
evidence_id="private-evidence-id-do-not-export",
|
||||
tenant_id=tenant_id,
|
||||
version="1",
|
||||
),
|
||||
),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
deadline_at=NOW + timedelta(days=30),
|
||||
change_reason="private-create-reason-do-not-export",
|
||||
metadata={"secret": "private-metadata-do-not-export"},
|
||||
)
|
||||
|
||||
|
||||
class CasesDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = CasesDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
|
||||
operator = _Principal("tenant-1", "operator-1")
|
||||
other_operator = _Principal("tenant-1", "operator-other")
|
||||
tenant_two_operator = _Principal("tenant-2", "operator-2")
|
||||
for principal in (operator, tenant_two_operator):
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
principal,
|
||||
status_key="open",
|
||||
label="Open",
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
principal,
|
||||
type_key="permit",
|
||||
label="Permit",
|
||||
initial_status_key="open",
|
||||
allowed_status_keys=("open",),
|
||||
)
|
||||
|
||||
create_case(
|
||||
self.session,
|
||||
operator,
|
||||
record=_record(
|
||||
"case-1",
|
||||
grants=(
|
||||
CaseGrant("account", "account-subject", ("read",)),
|
||||
CaseGrant("identity", "identity-subject", ("read", "update")),
|
||||
),
|
||||
),
|
||||
idempotency_key="private-create-key-do-not-export",
|
||||
)
|
||||
update_case(
|
||||
self.session,
|
||||
operator,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={
|
||||
"title": "Subject permit revised",
|
||||
"metadata": {"secret": "private-updated-metadata-do-not-export"},
|
||||
},
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="private-update-reason-do-not-export",
|
||||
idempotency_key="private-update-key-do-not-export",
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
other_operator,
|
||||
record=_record(
|
||||
"case-unrelated",
|
||||
title="private-unrelated-title-do-not-export",
|
||||
grants=(CaseGrant("account", "account-other", ("read",)),),
|
||||
),
|
||||
idempotency_key="unrelated-key-do-not-export",
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
tenant_two_operator,
|
||||
record=_record(
|
||||
"case-other-tenant",
|
||||
tenant_id="tenant-2",
|
||||
title="private-other-tenant-title-do-not-export",
|
||||
grants=(CaseGrant("account", "account-subject", ("read",)),),
|
||||
),
|
||||
idempotency_key="other-tenant-key-do-not-export",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_subject_access_grants_are_exact_tenant_and_minimized(self) -> None:
|
||||
account_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="account-subject"),
|
||||
)
|
||||
self.assertEqual(1, len(account_records))
|
||||
self.assertEqual("cases_access_grant", account_records[0].resource_type)
|
||||
self.assertFalse(account_records[0].immutable_evidence)
|
||||
self.assertEqual("account-subject", account_records[0].data["subject_id"])
|
||||
|
||||
identity_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(identity_id="identity-subject"),
|
||||
)
|
||||
self.assertEqual(1, len(identity_records))
|
||||
self.assertEqual("identity", identity_records[0].data["subject_kind"])
|
||||
|
||||
exported = json.dumps(
|
||||
[item.to_dict() for item in account_records + identity_records],
|
||||
sort_keys=True,
|
||||
)
|
||||
self.assertNotIn("private-unrelated-title-do-not-export", exported)
|
||||
self.assertNotIn("private-other-tenant-title-do-not-export", exported)
|
||||
self.assertNotIn("private-metadata-do-not-export", exported)
|
||||
self.assertNotIn("private-evidence-id-do-not-export", exported)
|
||||
|
||||
def test_direct_case_exports_typed_history_without_opaque_content(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||
)
|
||||
|
||||
types = [item.resource_type for item in records]
|
||||
self.assertEqual(1, types.count("cases_case_identity"))
|
||||
self.assertEqual(2, types.count("cases_case_revision"))
|
||||
self.assertEqual(1, types.count("cases_current_case_fact"))
|
||||
self.assertEqual(2, types.count("cases_timeline_event"))
|
||||
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
|
||||
self.assertIn("Subject permit revised", exported)
|
||||
self.assertNotIn("account-subject", exported)
|
||||
self.assertNotIn("identity-subject", exported)
|
||||
self.assertNotIn("private-party-reference-do-not-export", exported)
|
||||
self.assertNotIn("private-evidence-id-do-not-export", exported)
|
||||
self.assertNotIn("private-create-reason-do-not-export", exported)
|
||||
self.assertNotIn("private-update-reason-do-not-export", exported)
|
||||
self.assertNotIn("private-create-key-do-not-export", exported)
|
||||
self.assertNotIn("private-update-key-do-not-export", exported)
|
||||
self.assertNotIn("private-metadata-do-not-export", exported)
|
||||
self.assertNotIn("private-updated-metadata-do-not-export", exported)
|
||||
|
||||
def test_operator_attribution_excludes_raw_case_content(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="operator-1"),
|
||||
)
|
||||
self.assertGreaterEqual(len(records), 5)
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.resource_type
|
||||
in {"cases_operator_attribution", "cases_timeline_event"}
|
||||
for item in records
|
||||
)
|
||||
)
|
||||
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
|
||||
self.assertNotIn("Subject permit revised", exported)
|
||||
self.assertNotIn("account-subject", exported)
|
||||
self.assertNotIn("identity-subject", exported)
|
||||
self.assertNotIn("private-update-reason-do-not-export", exported)
|
||||
self.assertNotIn("private-updated-metadata-do-not-export", exported)
|
||||
|
||||
def test_direct_and_canonical_selector_conflicts_fail_closed(self) -> None:
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-subject",
|
||||
external_references={"cases.case": "case-unrelated"},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
|
||||
alias_conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-subject",
|
||||
external_references={"cases.account": "account-other"},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), alias_conflict)
|
||||
|
||||
def test_planning_retains_history_and_requires_current_fact_review(self) -> None:
|
||||
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
self.assertEqual(5, sum(item.kind == "retain" for item in actions))
|
||||
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
|
||||
self.assertTrue(all(not item.executable for item in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-cases-1",
|
||||
)
|
||||
self.assertTrue(all(item.status == "blocked" for item in results))
|
||||
|
||||
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||
subject = DsarSubjectRef(account_id="account-subject")
|
||||
foreign_record = DsarRecordRef(
|
||||
provider_id="foreign",
|
||||
module_id="foreign",
|
||||
resource_type="foreign",
|
||||
resource_id="foreign-1",
|
||||
category="foreign",
|
||||
title="Foreign record",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||
self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=(foreign_record,),
|
||||
)
|
||||
|
||||
foreign_action = DsarErasureActionRef(
|
||||
action_id="foreign:delete:1",
|
||||
provider_id="foreign",
|
||||
module_id="foreign",
|
||||
kind="delete",
|
||||
resource_type="foreign",
|
||||
resource_id="foreign-1",
|
||||
title="Delete foreign",
|
||||
rationale="No",
|
||||
executable=True,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(foreign_action,),
|
||||
request_id="dsar-cases-1",
|
||||
)
|
||||
|
||||
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
|
||||
active = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CASES-1",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-subject"),
|
||||
purpose="Subject access request",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-operator",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=active,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual("searched", active.status)
|
||||
self.assertEqual(
|
||||
[CASES_DSAR_CAPABILITY], active.coverage["provider_capabilities"]
|
||||
)
|
||||
self.assertEqual(["cases"], active.coverage["covered_modules"])
|
||||
|
||||
inactive = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CASES-2",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-subject"),
|
||||
purpose="Inactive module coverage",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-operator",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, cases_active=False),
|
||||
row=inactive,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||
self.assertEqual(
|
||||
[CASES_DSAR_CAPABILITY],
|
||||
inactive.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(0, inactive.search_result["record_count"])
|
||||
|
||||
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||
self.assertIn(
|
||||
CASES_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
topic.id == "cases.data-subject-requests"
|
||||
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||
for topic in manifest.documentation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.evidence_links import (
|
||||
link_case_evidence,
|
||||
linked_source,
|
||||
unlink_case_evidence,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
CaseStoreError,
|
||||
case_history,
|
||||
case_timeline,
|
||||
create_case,
|
||||
update_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
scopes: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def case_record() -> CaseRecord:
|
||||
case_ref = ref("case", "case-1", "cases")
|
||||
return CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number="PERMIT-2026-0001",
|
||||
case_type_key="permit",
|
||||
status_key="open",
|
||||
title="Resident permit",
|
||||
context=GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case intake.",
|
||||
),
|
||||
case_ref=case_ref,
|
||||
),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case created.",
|
||||
)
|
||||
|
||||
|
||||
def file_reference(**changes: str) -> dict[str, str]:
|
||||
result = {
|
||||
"owner_module": "files",
|
||||
"kind": "file-version",
|
||||
"object_id": "version-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"version": "version-1",
|
||||
"path": "/files?fileId=file-1&versionId=version-1",
|
||||
}
|
||||
result.update(changes)
|
||||
return result
|
||||
|
||||
|
||||
class CaseEvidenceLinkTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="open",
|
||||
label="Open",
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
self.principal,
|
||||
type_key="permit",
|
||||
label="Permit",
|
||||
initial_status_key="open",
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=case_record(),
|
||||
idempotency_key="create-case",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def link(self, **reference_changes: str) -> CaseRecord:
|
||||
return link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
reference=file_reference(**reference_changes),
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Linked exact file version.",
|
||||
idempotency_key="link-file-1",
|
||||
)
|
||||
|
||||
def test_link_appends_minimized_immutable_revision_and_audit_event(self) -> None:
|
||||
linked = self.link()
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(2, linked.revision)
|
||||
self.assertEqual(1, len(linked.evidence_refs))
|
||||
evidence = linked.evidence_refs[0]
|
||||
self.assertEqual("files", evidence.owner_module)
|
||||
self.assertEqual("version-1", evidence.version)
|
||||
self.assertTrue(evidence.evidence_id.startswith("linked-"))
|
||||
source = linked_source(evidence)
|
||||
self.assertEqual("file-version", source["kind"])
|
||||
self.assertEqual("/files?fileId=file-1&versionId=version-1", source["path"])
|
||||
self.assertNotIn("label", evidence.source_ref or "")
|
||||
self.assertNotIn("content", evidence.source_ref or "")
|
||||
|
||||
history = case_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
)
|
||||
timeline = case_timeline(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
)
|
||||
self.assertEqual([2, 1], [item.revision for item in history])
|
||||
self.assertEqual("cases.case.updated", timeline[0]["event_type"])
|
||||
self.assertNotIn(evidence.evidence_id, str(timeline[0]["payload"]))
|
||||
|
||||
def test_link_is_replay_safe_and_rejects_duplicate_or_stale_changes(self) -> None:
|
||||
first = self.link()
|
||||
replay = self.link()
|
||||
self.assertEqual(first.reference, replay.reference)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "already linked"):
|
||||
link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
reference=file_reference(),
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Duplicate link.",
|
||||
idempotency_key="duplicate-link",
|
||||
)
|
||||
|
||||
updated = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
changes={"title": "Updated permit"},
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Updated title.",
|
||||
idempotency_key="update-title",
|
||||
)
|
||||
self.assertEqual(3, updated.revision)
|
||||
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||
link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
reference={
|
||||
"owner_module": "campaigns",
|
||||
"kind": "campaign",
|
||||
"object_id": "campaign-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"version": "campaign-version-1",
|
||||
"path": "/campaigns/campaign-1",
|
||||
},
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Stale link.",
|
||||
idempotency_key="stale-link",
|
||||
)
|
||||
|
||||
def test_rejects_cross_tenant_unsupported_and_malformed_owner_paths(self) -> None:
|
||||
for changes, message in (
|
||||
({"tenant_id": "tenant-2"}, "cross Case tenants"),
|
||||
({"owner_module": "unknown"}, "not supported"),
|
||||
({"path": "/files?versionId=version-1"}, "exact authorized"),
|
||||
({"path": "https://example.invalid/file"}, "local platform"),
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
with self.assertRaisesRegex(CaseStoreError, message):
|
||||
self.link(**changes)
|
||||
|
||||
def test_unlink_only_removes_cases_owned_links_and_is_replay_safe(self) -> None:
|
||||
linked = self.link()
|
||||
self.session.commit()
|
||||
evidence_id = linked.evidence_refs[0].evidence_id
|
||||
unlinked = unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=2,
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Removed obsolete link.",
|
||||
idempotency_key="unlink-file-1",
|
||||
)
|
||||
replay = unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=2,
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Removed obsolete link.",
|
||||
idempotency_key="unlink-file-1",
|
||||
)
|
||||
self.assertEqual(3, unlinked.revision)
|
||||
self.assertEqual(unlinked.reference, replay.reference)
|
||||
self.assertEqual((), unlinked.evidence_refs)
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "not found"):
|
||||
unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id="ordinary-evidence",
|
||||
expected_revision=3,
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Invalid unlink.",
|
||||
idempotency_key="invalid-unlink",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,6 +26,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"cases.detail.timeline",
|
||||
"cases.detail.history",
|
||||
"cases.detail.access",
|
||||
"cases.detail.decision",
|
||||
},
|
||||
set(surfaces),
|
||||
)
|
||||
@@ -37,6 +38,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"cases.detail.timeline",
|
||||
"cases.detail.history",
|
||||
"cases.detail.access",
|
||||
"cases.detail.decision",
|
||||
):
|
||||
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
|
||||
|
||||
@@ -44,12 +46,17 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||
context = topics["cases.institutional-context"]
|
||||
reference = topics["cases.reference.lifecycle-access-and-evidence"]
|
||||
owner_links = topics["cases.workflow.link-owner-references"]
|
||||
|
||||
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
|
||||
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
|
||||
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
||||
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
||||
self.assertIn("change_access", reference.metadata["consequence_classes"])
|
||||
self.assertIn(
|
||||
"link_owner_reference",
|
||||
owner_links.metadata["consequence_classes"],
|
||||
)
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
list_page = (
|
||||
@@ -68,6 +75,9 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"DocumentationHelpLink",
|
||||
"FormField",
|
||||
"useUnsavedDraftGuard",
|
||||
"QUICK_ACCESS_RESULT_EVENT",
|
||||
"linkCaseEvidence",
|
||||
"unlinkCaseEvidence",
|
||||
):
|
||||
self.assertIn(component, detail_page)
|
||||
for component in (
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
|
||||
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||
from govoplan_cases.backend.record_source import CasesRecordSource
|
||||
|
||||
|
||||
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope == "cases:case:read"
|
||||
|
||||
|
||||
class CasesRecordSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
CaseIdentity.__table__.create(self.engine)
|
||||
CaseRecordRevision.__table__.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
identity = CaseIdentity(
|
||||
id="identity-1",
|
||||
tenant_id="tenant-1",
|
||||
case_id="case-1",
|
||||
case_number="2026/C-1",
|
||||
created_by="account-1",
|
||||
)
|
||||
revision = CaseRecordRevision(
|
||||
id="revision-1",
|
||||
tenant_id="tenant-1",
|
||||
case_id="case-1",
|
||||
identity_id="identity-1",
|
||||
revision=3,
|
||||
case_type_key="permit",
|
||||
status_key="review",
|
||||
title="Permit application",
|
||||
access_mode="restricted",
|
||||
search_text="permit application",
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
snapshot={
|
||||
"case_id": "case-1",
|
||||
"revision": 3,
|
||||
"title": "Permit application",
|
||||
},
|
||||
)
|
||||
self.session.add_all((identity, revision))
|
||||
self.session.flush()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def locator(self) -> RecordSourceLocator:
|
||||
return RecordSourceLocator(
|
||||
tenant_id="tenant-1",
|
||||
source_module="cases",
|
||||
resource_type="case_revision",
|
||||
resource_id="case-1",
|
||||
source_revision="3",
|
||||
)
|
||||
|
||||
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
||||
with patch(
|
||||
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
||||
):
|
||||
result = CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
locator=self.locator(),
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
self.assertEqual("2026/C-1 - Permit application", result.label)
|
||||
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||
self.assertEqual("review", result.metadata["status_key"])
|
||||
|
||||
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_cases.backend.record_source.can_access_case",
|
||||
return_value=False,
|
||||
),
|
||||
self.assertRaisesRegex(RecordContractError, "cannot read"),
|
||||
):
|
||||
CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
locator=self.locator(),
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||
from govoplan_core.core.institutional import (
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class CasesSearchSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
principal = _principal({"cases:case:read", "cases:case:create"})
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
principal,
|
||||
status_key="review",
|
||||
label="Review",
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
principal,
|
||||
type_key="permit",
|
||||
label="Permit",
|
||||
initial_status_key="review",
|
||||
)
|
||||
case_ref = _ref("case", "case-1", "cases")
|
||||
create_case(
|
||||
self.session,
|
||||
principal,
|
||||
record=CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number="PERMIT-1",
|
||||
case_type_key="permit",
|
||||
status_key="review",
|
||||
title="Permit application",
|
||||
context=GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Created.",
|
||||
),
|
||||
case_ref=case_ref,
|
||||
),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Created.",
|
||||
),
|
||||
idempotency_key="case-create",
|
||||
)
|
||||
self.session.commit()
|
||||
self.source = CasesSearchSource()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_backfill_authorization_and_event_change_are_bounded(self) -> None:
|
||||
page = self.source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type="case",
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
|
||||
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
)
|
||||
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({"cases:case:read"}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal(set()),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
changes = self.source.index_changes_for_event(
|
||||
self.session,
|
||||
event=PlatformEvent(
|
||||
type="cases.case.updated",
|
||||
module_id="cases",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type="case", id="case-1"),
|
||||
),
|
||||
delivery_key="delivery-1",
|
||||
)
|
||||
self.assertEqual("upsert", changes[0].kind)
|
||||
|
||||
|
||||
def _ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="membership-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/cases.css": "./src/styles/cases.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -21,6 +21,17 @@ export type EvidenceReference = {
|
||||
evidence_id: string;
|
||||
tenant_id: string;
|
||||
version?: string | null;
|
||||
source_ref?: string | null;
|
||||
derived_from?: string[];
|
||||
};
|
||||
|
||||
export type OwnerReference = {
|
||||
ownerModule: string;
|
||||
kind: string;
|
||||
objectId: string;
|
||||
tenantId: string;
|
||||
version?: string | null;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
export type CaseRecord = {
|
||||
@@ -96,6 +107,33 @@ export type CaseTimelineEntry = {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormalDecision = {
|
||||
reference: InstitutionalReference;
|
||||
temporal: {
|
||||
revision: string;
|
||||
valid_from?: string | null;
|
||||
valid_to?: string | null;
|
||||
recorded_at?: string | null;
|
||||
change_reason?: string | null;
|
||||
};
|
||||
decision_type: string;
|
||||
state: string;
|
||||
assurance_level: string;
|
||||
operative_result?: string | null;
|
||||
reasoning?: string | null;
|
||||
conditions: string[];
|
||||
authority_context: Record<string, unknown>;
|
||||
delivery_refs: string[];
|
||||
remedy_refs: string[];
|
||||
review_refs: string[];
|
||||
};
|
||||
|
||||
export type CaseDecisionResult = {
|
||||
case: CaseRecord;
|
||||
decision: FormalDecision;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export function listCases(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
@@ -147,6 +185,42 @@ export function caseTimeline(
|
||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
||||
}
|
||||
|
||||
export function listCaseDecisions(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ decisions: FormalDecision[] }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function recordCaseDecision(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
payload: {
|
||||
expected_case_revision: number;
|
||||
effective_at: string;
|
||||
decision_type: string;
|
||||
operative_result: string;
|
||||
reasoning: string;
|
||||
conditions: string[];
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
): Promise<CaseDecisionResult> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCase(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
@@ -167,6 +241,49 @@ export function updateCase(
|
||||
});
|
||||
}
|
||||
|
||||
export function linkCaseEvidence(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
reference: {
|
||||
owner_module: string;
|
||||
kind: string;
|
||||
object_id: string;
|
||||
tenant_id: string;
|
||||
version: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function unlinkCaseEvidence(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
evidenceId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function caseShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Scale } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormGrid,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
i18nMessage,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
recordCaseDecision,
|
||||
type CaseDecisionResult,
|
||||
type CaseRecord
|
||||
} from "../../api/cases";
|
||||
import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
||||
|
||||
|
||||
export default function CaseDecisionDialog({
|
||||
settings,
|
||||
record,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (result: CaseDecisionResult) => void;
|
||||
}) {
|
||||
const [decisionType, setDecisionType] = useState("");
|
||||
const [effectiveAt, setEffectiveAt] = useState("");
|
||||
const [operativeResult, setOperativeResult] = useState("");
|
||||
const [reasoning, setReasoning] = useState("");
|
||||
const [conditions, setConditions] = useState("");
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDecisionType(defaultDecisionType(record));
|
||||
setEffectiveAt(dateTimeLocalValue(new Date()));
|
||||
setOperativeResult("");
|
||||
setReasoning("");
|
||||
setConditions("");
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
setConfirmOpen(false);
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
}, [open, record]);
|
||||
|
||||
const disabledReason = useMemo(() => {
|
||||
if (busy) return "The formal Decision is being recorded.";
|
||||
if (
|
||||
!decisionType.trim()
|
||||
|| !effectiveAt
|
||||
|| !operativeResult.trim()
|
||||
|| !reasoning.trim()
|
||||
|| !changeReason.trim()
|
||||
) {
|
||||
return "Complete the Decision type, effective time, result, reasoning, and change reason.";
|
||||
}
|
||||
return undefined;
|
||||
}, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]);
|
||||
|
||||
async function save() {
|
||||
if (disabledReason) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await recordCaseDecision(settings, record.reference.object_id, {
|
||||
expected_case_revision: record.revision,
|
||||
effective_at: new Date(effectiveAt).toISOString(),
|
||||
decision_type: decisionType.trim(),
|
||||
operative_result: operativeResult.trim(),
|
||||
reasoning: reasoning.trim(),
|
||||
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
onSaved(result);
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setConfirmOpen(false);
|
||||
setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="case-decision-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabledReason={disabledReason}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
helpContextId="cases.action.decide"
|
||||
>
|
||||
<Scale size={16} aria-hidden="true" />
|
||||
Record decision
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="case-decision-content" data-help-context-id="cases.decision.editor">
|
||||
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="case-decision-explanation">
|
||||
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
|
||||
</p>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="case-decision-grid">
|
||||
<FormField label="Decision type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={decisionType}
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
onChange={(event) => setDecisionType(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Effective at" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={effectiveAt}
|
||||
disabled={busy}
|
||||
onChange={(event) => setEffectiveAt(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<FormField label="Operative result" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={operativeResult}
|
||||
disabled={busy}
|
||||
maxLength={20_000}
|
||||
onChange={(event) => setOperativeResult(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Reasoning" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={7}
|
||||
value={reasoning}
|
||||
disabled={busy}
|
||||
maxLength={50_000}
|
||||
onChange={(event) => setReasoning(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Conditions (one per line)" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={conditions}
|
||||
disabled={busy}
|
||||
onChange={(event) => setConditions(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={changeReason}
|
||||
disabled={busy}
|
||||
maxLength={1_000}
|
||||
onChange={(event) => setChangeReason(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Record formal Decision"
|
||||
message="Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision."
|
||||
confirmLabel="Record decision"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => void save()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function defaultDecisionType(record: CaseRecord): string {
|
||||
const resultRefs = Array.isArray(record.metadata.result_refs)
|
||||
? record.metadata.result_refs
|
||||
: [];
|
||||
const result = resultRefs.find((item): item is string => typeof item === "string");
|
||||
if (result) return result.replace(/^decision:/, "");
|
||||
return record.case_type_key.replace(/-application$/, "");
|
||||
}
|
||||
|
||||
|
||||
function dateTimeLocalValue(value: Date): string {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft, Save, Share2 } from "lucide-react";
|
||||
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
import { ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
@@ -11,23 +11,34 @@ import {
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
QUICK_ACCESS_RESULT_EVENT,
|
||||
hasScope,
|
||||
usePlatformModuleInstalled,
|
||||
useGuardedNavigate,
|
||||
useRegisterActiveObject,
|
||||
useUnsavedDraftGuard,
|
||||
type PlatformRouteContext
|
||||
type PlatformRouteContext,
|
||||
type QuickAccessLaunchContext,
|
||||
type QuickAccessResult
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
caseHistory,
|
||||
caseTimeline,
|
||||
getCase,
|
||||
linkCaseEvidence,
|
||||
listCaseDecisions,
|
||||
listCaseCatalog,
|
||||
updateCase,
|
||||
unlinkCaseEvidence,
|
||||
type CaseCatalog,
|
||||
type CaseRecord,
|
||||
type CaseTimelineEntry,
|
||||
type EvidenceReference,
|
||||
type FormalDecision,
|
||||
type InstitutionalReference
|
||||
} from "../../api/cases";
|
||||
import CaseShareDialog from "./CaseShareDialog";
|
||||
import CaseDecisionDialog from "./CaseDecisionDialog";
|
||||
import {
|
||||
CASES_DOCUMENTATION,
|
||||
CASES_FIELDS_DOCUMENTATION,
|
||||
@@ -42,6 +53,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||
const [history, setHistory] = useState<CaseRecord[]>([]);
|
||||
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
|
||||
const [decisions, setDecisions] = useState<FormalDecision[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
@@ -49,10 +61,40 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [decisionOpen, setDecisionOpen] = useState(false);
|
||||
const [linkingEvidence, setLinkingEvidence] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const canUpdate = hasScope(auth, "cases:case:update");
|
||||
const canClose = hasScope(auth, "cases:case:close");
|
||||
const canShare = hasScope(auth, "cases:case:share");
|
||||
const decisionsAvailable = usePlatformModuleInstalled("decisions");
|
||||
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
||||
const accessAvailable = usePlatformModuleInstalled("access");
|
||||
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||
const filesAvailable = usePlatformModuleInstalled("files");
|
||||
const mailAvailable = usePlatformModuleInstalled("mail");
|
||||
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
|
||||
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
|
||||
const decisionDisabledReason = !canUpdate
|
||||
? "Your account may not update this Case."
|
||||
: !hasScope(auth, "decisions:decision:write")
|
||||
? "Your account may not record formal Decisions."
|
||||
: !accessAvailable
|
||||
? "Enable Access to verify the acting assignment."
|
||||
: !mandatesAvailable
|
||||
? "Enable Mandates to verify formal authority."
|
||||
: undefined;
|
||||
const canFileRecords = recordsAvailable && hasScope(auth, "records:workspace:write");
|
||||
const activeCase = useMemo(() => record ? {
|
||||
ownerModule: record.reference.owner_module,
|
||||
kind: record.reference.kind,
|
||||
objectId: record.reference.object_id,
|
||||
tenantId: record.reference.tenant_id,
|
||||
label: `${record.case_number} · ${record.title}`,
|
||||
version: record.reference.version ?? String(record.revision),
|
||||
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
|
||||
} : null, [record]);
|
||||
useRegisterActiveObject(activeCase);
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
@@ -61,19 +103,23 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
getCase(settings, caseId, signal),
|
||||
listCaseCatalog(settings, signal),
|
||||
caseHistory(settings, caseId, signal),
|
||||
caseTimeline(settings, caseId, signal)
|
||||
caseTimeline(settings, caseId, signal),
|
||||
canReadDecisions
|
||||
? listCaseDecisions(settings, caseId, signal)
|
||||
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
||||
]).
|
||||
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
|
||||
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
||||
setRecord(nextRecord);
|
||||
setCatalog(nextCatalog);
|
||||
setHistory(nextHistory.revisions);
|
||||
setTimeline(nextTimeline.entries);
|
||||
setDecisions(nextDecisions.decisions);
|
||||
setTitle(nextRecord.title);
|
||||
setStatus(nextRecord.status_key);
|
||||
setChangeReason("");
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
}, [caseId, settings]);
|
||||
}, [canReadDecisions, caseId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -96,6 +142,69 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
|
||||
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!record || !canUpdate) return;
|
||||
const receiveReference = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
launchContext?: QuickAccessLaunchContext;
|
||||
result?: QuickAccessResult;
|
||||
}>).detail;
|
||||
const sourceCase = detail?.launchContext?.activeObject;
|
||||
const result = detail?.result;
|
||||
if (
|
||||
!sourceCase
|
||||
|| sourceCase.ownerModule !== "cases"
|
||||
|| sourceCase.kind !== "case"
|
||||
|| sourceCase.objectId !== record.reference.object_id
|
||||
|| sourceCase.tenantId !== record.reference.tenant_id
|
||||
|| result?.outcome !== "completed"
|
||||
|| result.action !== "selected"
|
||||
|| !result.reference
|
||||
) return;
|
||||
if (draftDirty) {
|
||||
setError(
|
||||
"Save or discard the current Case edits before linking the selected reference."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reference = result.reference;
|
||||
if (!reference.version || !reference.path) {
|
||||
setError("The selected owner reference is not an exact versioned link.");
|
||||
return;
|
||||
}
|
||||
setLinkingEvidence(true);
|
||||
setError("");
|
||||
void linkCaseEvidence(settings, caseId, {
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
reference: {
|
||||
owner_module: reference.ownerModule,
|
||||
kind: reference.kind,
|
||||
object_id: reference.objectId,
|
||||
tenant_id: reference.tenantId,
|
||||
version: reference.version,
|
||||
path: reference.path
|
||||
}
|
||||
}).then((saved) => {
|
||||
setRecord(saved);
|
||||
setTitle(saved.title);
|
||||
setStatus(saved.status_key);
|
||||
return load();
|
||||
}).catch((reason) => {
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "The selected reference could not be linked to this Case.");
|
||||
}).finally(() => setLinkingEvidence(false));
|
||||
};
|
||||
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
|
||||
return () => window.removeEventListener(
|
||||
QUICK_ACCESS_RESULT_EVENT,
|
||||
receiveReference
|
||||
);
|
||||
}, [canUpdate, caseId, draftDirty, load, record, settings]);
|
||||
|
||||
function discardDraft() {
|
||||
if (!record) return;
|
||||
setTitle(record.title);
|
||||
@@ -137,6 +246,36 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
|
||||
if (!record || draftDirty || linkingEvidence) return;
|
||||
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
|
||||
setLinkingEvidence(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await unlinkCaseEvidence(
|
||||
settings,
|
||||
caseId,
|
||||
evidence.evidence_id,
|
||||
{
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
}
|
||||
);
|
||||
setRecord(saved);
|
||||
setTitle(saved.title);
|
||||
setStatus(saved.status_key);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "The owner reference could not be removed from this Case.");
|
||||
} finally {
|
||||
setLinkingEvidence(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
title: "i18n:govoplan-cases.unsaved_title",
|
||||
@@ -156,21 +295,41 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
return (
|
||||
<main className="cases-page">
|
||||
<div className="case-detail-shell">
|
||||
<div className="case-detail-toolbar">
|
||||
<ActionToolbar className="case-detail-toolbar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
Cases
|
||||
</button>
|
||||
{record && <span>{record.case_number}</span>}
|
||||
{record ? <IconButton
|
||||
label="Manage case access"
|
||||
icon={<Share2 size={16} />}
|
||||
className="case-share-button"
|
||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||
onClick={() => setShareOpen(true)}
|
||||
/> : null}
|
||||
{record ? (
|
||||
<div className="case-detail-actions">
|
||||
{decisionsAvailable ? (
|
||||
<IconButton
|
||||
label="Record formal decision"
|
||||
icon={<Scale size={16} />}
|
||||
disabledReason={decisionDisabledReason}
|
||||
onClick={() => setDecisionOpen(true)}
|
||||
helpContextId="cases.action.decide"
|
||||
/>
|
||||
) : null}
|
||||
{canFileRecords ? (
|
||||
<IconButton
|
||||
label="File Case in eAkte"
|
||||
icon={<Archive size={16} />}
|
||||
onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))}
|
||||
helpContextId="records.action.file"
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
label="Manage case access"
|
||||
icon={<Share2 size={16} />}
|
||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||
onClick={() => setShareOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<PageScrollViewport className="case-detail-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
@@ -250,7 +409,30 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
|
||||
<ReferenceSection title="Parties" references={record.party_refs} />
|
||||
<ReferenceSection title="Assignments" references={record.assignment_refs} />
|
||||
<ReferenceSection title="Decisions" references={record.decision_refs} />
|
||||
<EvidenceSection
|
||||
references={record.evidence_refs}
|
||||
providers={{
|
||||
files: filesAvailable,
|
||||
mail: mailAvailable,
|
||||
campaigns: campaignsAvailable
|
||||
}}
|
||||
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
|
||||
onUnlink={(evidence) => void unlinkEvidence(evidence)}
|
||||
/>
|
||||
{canReadDecisions ? (
|
||||
<DecisionSection
|
||||
decisions={decisions}
|
||||
canFile={canFileRecords}
|
||||
onFile={(decision) => navigate(recordFilingPath(
|
||||
decision.reference,
|
||||
`${humanize(decision.decision_type)} Decision`,
|
||||
"decisions",
|
||||
"decision_revision"
|
||||
))}
|
||||
/>
|
||||
) : (
|
||||
<ReferenceSection title="Decisions" references={record.decision_refs} />
|
||||
)}
|
||||
<ReferenceSection title="Records" references={record.record_refs} />
|
||||
</section>
|
||||
|
||||
@@ -298,6 +480,24 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{record ? (
|
||||
<CaseDecisionDialog
|
||||
settings={settings}
|
||||
record={record}
|
||||
open={decisionOpen}
|
||||
onClose={() => setDecisionOpen(false)}
|
||||
onSaved={(result) => {
|
||||
setRecord(result.case);
|
||||
setDecisions((current) => [
|
||||
result.decision,
|
||||
...current.filter((item) => item.reference.object_id !== result.decision.reference.object_id)
|
||||
]);
|
||||
void load().catch((reason) => {
|
||||
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -323,6 +523,151 @@ function ReferenceSection({ title, references }: { title: string; references: In
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceSection({
|
||||
references,
|
||||
providers,
|
||||
canUnlink,
|
||||
onUnlink
|
||||
}: {
|
||||
references: EvidenceReference[];
|
||||
providers: Record<string, boolean>;
|
||||
canUnlink: boolean;
|
||||
onUnlink: (reference: EvidenceReference) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="case-reference-section case-evidence-section">
|
||||
<h2>Linked files, mail, and campaigns</h2>
|
||||
<p className="muted small-note">
|
||||
Select an authorized exact item in Quick Access to link it. Cases stores
|
||||
only the owner reference; the owning module checks access again when opened.
|
||||
</p>
|
||||
{references.length ? (
|
||||
<div className="case-reference-list">
|
||||
{references.map((reference) => {
|
||||
const source = linkedEvidenceSource(reference);
|
||||
const available = source
|
||||
? providers[source.owner_module] !== false
|
||||
: providers[reference.owner_module] !== false;
|
||||
return (
|
||||
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
|
||||
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
|
||||
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
|
||||
{source && available ? (
|
||||
<a href={source.path} className="btn btn-secondary">
|
||||
<ExternalLink size={15} aria-hidden="true" /> Open in owner
|
||||
</a>
|
||||
) : source ? (
|
||||
<small>Owner module unavailable; the reference remains in Case history.</small>
|
||||
) : null}
|
||||
{source ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!canUnlink}
|
||||
disabledReason={!canUnlink
|
||||
? "Save or discard Case edits before removing a link."
|
||||
: undefined}
|
||||
onClick={() => onUnlink(reference)}
|
||||
>
|
||||
<Unlink size={15} aria-hidden="true" /> Unlink
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">No owner references are linked.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function linkedEvidenceSource(reference: EvidenceReference): {
|
||||
owner_module: string;
|
||||
kind: string;
|
||||
path: string;
|
||||
} | null {
|
||||
if (!reference.source_ref) return null;
|
||||
try {
|
||||
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
|
||||
const owner = String(value.owner_module ?? "");
|
||||
const kind = String(value.kind ?? "");
|
||||
const path = String(value.path ?? "");
|
||||
if (
|
||||
value.contract !== "cases.quick-access-reference.v1"
|
||||
|| !owner
|
||||
|| !kind
|
||||
|| !path.startsWith("/")
|
||||
|| path.startsWith("//")
|
||||
) return null;
|
||||
return { owner_module: owner, kind, path };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DecisionSection({
|
||||
decisions,
|
||||
canFile,
|
||||
onFile
|
||||
}: {
|
||||
decisions: FormalDecision[];
|
||||
canFile: boolean;
|
||||
onFile: (decision: FormalDecision) => void;
|
||||
}) {
|
||||
if (decisions.length === 0) return null;
|
||||
return (
|
||||
<section className="case-reference-section">
|
||||
<h2>Decisions</h2>
|
||||
<div className="case-decision-list">
|
||||
{decisions.map((decision) => (
|
||||
<article key={`${decision.reference.object_id}:${decision.reference.version ?? "current"}`}>
|
||||
<div className="case-decision-heading">
|
||||
<div>
|
||||
<strong>{humanize(decision.decision_type)}</strong>
|
||||
<span>Revision {decision.reference.version ?? decision.temporal.revision}</span>
|
||||
</div>
|
||||
<StatusBadge status={decision.state === "effective" || decision.state === "decided" ? "active" : "inactive"} label={humanize(decision.state)} />
|
||||
{canFile ? (
|
||||
<IconButton
|
||||
label="File Decision in eAkte"
|
||||
icon={<Archive size={16} />}
|
||||
onClick={() => onFile(decision)}
|
||||
helpContextId="records.action.file"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{decision.operative_result ? <p><strong>Operative result</strong>{decision.operative_result}</p> : null}
|
||||
{decision.reasoning ? <p><strong>Reasoning</strong>{decision.reasoning}</p> : null}
|
||||
{!decision.operative_result && !decision.reasoning ? (
|
||||
<p className="case-decision-protected">Protected Decision details require the sensitive-read permission.</p>
|
||||
) : null}
|
||||
{decision.conditions.length > 0 ? (
|
||||
<ul>{decision.conditions.map((condition) => <li key={condition}>{condition}</li>)}</ul>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function recordFilingPath(
|
||||
reference: InstitutionalReference,
|
||||
label: string,
|
||||
sourceModule: string,
|
||||
resourceType: string
|
||||
): string {
|
||||
const query = new URLSearchParams({
|
||||
sourceModule,
|
||||
resourceType,
|
||||
resourceId: reference.object_id,
|
||||
sourceRevision: reference.version ?? "",
|
||||
sourceLabel: label
|
||||
});
|
||||
return `/records?${query.toString()}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
import { Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FilterBar,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
i18nMessage,
|
||||
useGuardedNavigate,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -30,6 +36,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -55,7 +62,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [settings, status, submittedQuery]);
|
||||
}, [reloadKey, settings, status, submittedQuery]);
|
||||
|
||||
const statusLabels = useMemo(
|
||||
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
||||
@@ -73,9 +80,15 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
|
||||
return (
|
||||
<main className="cases-page">
|
||||
<div className="cases-shell">
|
||||
<div className="cases-toolbar">
|
||||
<form className="cases-search" onSubmit={submit}>
|
||||
<WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
|
||||
<WorkspaceActionBar
|
||||
scope="collection-pane"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
|
||||
className="cases-toolbar"
|
||||
contextActions={<>
|
||||
<FilterBar as="form" surface="control" wrap="never" width="default" className="cases-search" onSubmit={submit}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
@@ -90,8 +103,8 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<label className="cases-status-filter">
|
||||
</FilterBar>
|
||||
<label className="cases-status-filter">
|
||||
<span>Status</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
@@ -99,10 +112,11 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
<option key={item.status_key} value={item.status_key}>{item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
|
||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||
</div>
|
||||
</label>
|
||||
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
|
||||
</>}
|
||||
helpAction={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
|
||||
/>
|
||||
<PageScrollViewport className="cases-list-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||
@@ -111,36 +125,29 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
}
|
||||
{loading && <LoadingIndicator label="Loading cases" />}
|
||||
{!loading && !error && cases.length === 0 &&
|
||||
<div className="cases-empty">No matching cases.</div>
|
||||
<StatePanel size="compact" description="No matching cases." />
|
||||
}
|
||||
{!loading && cases.length > 0 &&
|
||||
<div className="cases-list" role="list">
|
||||
<SelectionList variant="navigation" label="Cases">
|
||||
{cases.map((item) => {
|
||||
const statusDefinition = statusLabels.get(item.status_key);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="case-list-row"
|
||||
role="listitem"
|
||||
<SelectionListItem
|
||||
selected={false}
|
||||
key={item.reference.object_id}
|
||||
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
|
||||
<div className="case-list-primary">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.case_number}</span>
|
||||
</div>
|
||||
<span>{typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)}</span>
|
||||
<span>{formatDate(item.deadline_at)}</span>
|
||||
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
|
||||
<StatusBadge
|
||||
status={statusDefinition?.terminal ? "inactive" : "active"}
|
||||
label={statusDefinition?.label ?? humanize(item.status_key)}
|
||||
/>
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SelectionList>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</WorkspaceFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const en = {
|
||||
"i18n:govoplan-cases.timeline": "Case timeline",
|
||||
"i18n:govoplan-cases.history": "Immutable case history",
|
||||
"i18n:govoplan-cases.access": "Case access",
|
||||
"i18n:govoplan-cases.decision": "Record formal Decision",
|
||||
"i18n:govoplan-cases.decision_title": "Record formal Decision - {value0}",
|
||||
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
|
||||
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
|
||||
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
|
||||
@@ -78,7 +80,27 @@ const en = {
|
||||
"Target": "Target",
|
||||
"Permission": "Permission",
|
||||
"Save access": "Save access",
|
||||
"No explicit access grants.": "No explicit access grants."
|
||||
"No explicit access grants.": "No explicit access grants.",
|
||||
"Record formal decision": "Record formal decision",
|
||||
"File Case in eAkte": "File Case in eAkte",
|
||||
"File Decision in eAkte": "File Decision in eAkte",
|
||||
"Record decision": "Record decision",
|
||||
"The formal Decision is being recorded.": "The formal Decision is being recorded.",
|
||||
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Complete the Decision type, effective time, result, reasoning, and change reason.",
|
||||
"The formal Decision could not be recorded.": "The formal Decision could not be recorded.",
|
||||
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.",
|
||||
"Decision type": "Decision type",
|
||||
"Effective at": "Effective at",
|
||||
"Operative result": "Operative result",
|
||||
"Reasoning": "Reasoning",
|
||||
"Conditions (one per line)": "Conditions (one per line)",
|
||||
"Record formal Decision": "Record formal Decision",
|
||||
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.",
|
||||
"Protected Decision details require the sensitive-read permission.": "Protected Decision details require the sensitive-read permission.",
|
||||
"Your account may not update this Case.": "Your account may not update this Case.",
|
||||
"Your account may not record formal Decisions.": "Your account may not record formal Decisions.",
|
||||
"Enable Access to verify the acting assignment.": "Enable Access to verify the acting assignment.",
|
||||
"Enable Mandates to verify formal authority.": "Enable Mandates to verify formal authority."
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
@@ -93,6 +115,8 @@ const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
|
||||
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
|
||||
"i18n:govoplan-cases.access": "Vorgangszugriff",
|
||||
"i18n:govoplan-cases.decision": "Formelle Entscheidung erfassen",
|
||||
"i18n:govoplan-cases.decision_title": "Formelle Entscheidung erfassen - {value0}",
|
||||
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
|
||||
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
|
||||
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
|
||||
@@ -159,7 +183,27 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Target": "Ziel",
|
||||
"Permission": "Berechtigung",
|
||||
"Save access": "Zugriff speichern",
|
||||
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben."
|
||||
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
||||
"Record formal decision": "Formelle Entscheidung erfassen",
|
||||
"File Case in eAkte": "Vorgang in eAkte verakten",
|
||||
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
||||
"Record decision": "Entscheidung erfassen",
|
||||
"The formal Decision is being recorded.": "Die formelle Entscheidung wird erfasst.",
|
||||
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Vervollständigen Sie Entscheidungsart, Gültigkeitszeitpunkt, Entscheidungssatz, Begründung und Änderungsbegründung.",
|
||||
"The formal Decision could not be recorded.": "Die formelle Entscheidung konnte nicht erfasst werden.",
|
||||
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "Der Server prüft vor der Erfassung den aktuellen Funktionskontext, das wirksame Mandat, die exakte Vorgangsrevision, die Nachweise und die Rechtsgrundlage.",
|
||||
"Decision type": "Entscheidungsart",
|
||||
"Effective at": "Gültig ab",
|
||||
"Operative result": "Entscheidungssatz",
|
||||
"Reasoning": "Begründung",
|
||||
"Conditions (one per line)": "Nebenbestimmungen (eine pro Zeile)",
|
||||
"Record formal Decision": "Formelle Entscheidung erfassen",
|
||||
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Diese formelle Entscheidung zur exakten Vorgangsrevision erfassen? Entscheidungssatz und Begründung werden als unveränderliche Entscheidungsrevision gespeichert.",
|
||||
"Protected Decision details require the sensitive-read permission.": "Geschützte Entscheidungsinhalte erfordern die Berechtigung zum Lesen sensibler Entscheidungen.",
|
||||
"Your account may not update this Case.": "Ihr Konto darf diesen Vorgang nicht ändern.",
|
||||
"Your account may not record formal Decisions.": "Ihr Konto darf keine formellen Entscheidungen erfassen.",
|
||||
"Enable Access to verify the acting assignment.": "Aktivieren Sie Access, damit der Funktionskontext geprüft werden kann.",
|
||||
"Enable Mandates to verify formal authority.": "Aktivieren Sie Mandates, damit die formelle Zuständigkeit geprüft werden kann."
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
|
||||
+5
-3
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
|
||||
export const casesModule: PlatformWebModule = {
|
||||
id: "cases",
|
||||
label: "i18n:govoplan-cases.cases",
|
||||
version: "0.1.8",
|
||||
version: "0.1.20",
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"addresses",
|
||||
@@ -19,7 +19,8 @@ export const casesModule: PlatformWebModule = {
|
||||
"mandates",
|
||||
"decisions",
|
||||
"forms_runtime",
|
||||
"workflow_engine"
|
||||
"workflow_engine",
|
||||
"records"
|
||||
],
|
||||
translations: generatedTranslations,
|
||||
routes: [
|
||||
@@ -58,7 +59,8 @@ export const casesModule: PlatformWebModule = {
|
||||
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
|
||||
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
|
||||
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
|
||||
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 }
|
||||
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 },
|
||||
{ id: "cases.detail.decision", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.decision", parentId: "cases.detail", order: 70 }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
+93
-93
@@ -1,19 +1,16 @@
|
||||
.cases-page,
|
||||
.cases-shell,
|
||||
.case-detail-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cases-shell,
|
||||
.case-detail-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.cases-toolbar,
|
||||
.case-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -24,17 +21,7 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.cases-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.cases-search input {
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
.cases-search { flex: 1 1 560px; }
|
||||
|
||||
.cases-status-filter {
|
||||
display: flex;
|
||||
@@ -52,7 +39,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.case-share-button {
|
||||
.case-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -63,58 +53,6 @@
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
|
||||
.cases-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.case-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 2fr) minmax(150px, 1fr) minmax(150px, 0.8fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-height: 66px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.case-list-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.case-list-row:hover,
|
||||
.case-list-row:focus-visible {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.case-list-primary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.case-list-primary strong,
|
||||
.case-list-primary span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.case-list-primary span,
|
||||
.case-list-row > span:not(.status-badge) {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.case-detail-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
@@ -159,7 +97,7 @@
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
@@ -190,7 +128,7 @@
|
||||
margin-top: 18px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -238,6 +176,15 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.case-evidence-section .case-reference-list > span {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.case-evidence-section .case-reference-list small {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.case-timeline {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -254,17 +201,83 @@
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.cases-empty {
|
||||
padding: 36px 0;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.case-share-dialog {
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
.case-decision-dialog {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
max-height: min(820px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
.case-decision-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.case-decision-explanation,
|
||||
.case-decision-protected {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.case-decision-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.7fr);
|
||||
}
|
||||
|
||||
.case-decision-content textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.case-decision-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.case-decision-list > article {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.case-decision-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.case-decision-heading > div:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.case-decision-heading span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.case-decision-list p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin: 10px 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.case-decision-list ul {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.case-share-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -322,28 +335,11 @@
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.cases-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cases-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cases-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.case-list-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.case-list-row > span:not(.status-badge) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.case-detail-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -352,6 +348,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.case-detail-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.case-share-add-row .icon-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user