Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e03c3e77e | ||
|
|
0337e0cf0b | ||
|
|
afda3a5ac5 | ||
|
|
17415d8831 | ||
|
|
9435116299 | ||
|
|
e1cffd3e53 | ||
|
|
d395b22301 | ||
|
|
d3a70f87cd | ||
|
|
f236034e88 | ||
|
|
eab2f4b3b2 | ||
|
|
fd7f05ffc9 | ||
|
|
2efe574f18 | ||
|
|
83e69565fe | ||
|
|
86878ef7a7 | ||
|
|
23f5091a2b | ||
|
|
2f030e56a9 | ||
|
|
cfe2389e5a | ||
|
|
0265e135f5 | ||
|
|
ee01662b07 |
@@ -26,15 +26,45 @@ 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`
|
||||
workspace now supplies list/detail, status/title revision, history, and timeline
|
||||
surfaces. A case can remain tenant-visible or become restricted to its creator,
|
||||
case administrators, explicit user/group grants, and assignment-derived
|
||||
function, function-assignment, or organization-unit grants. The detail surface
|
||||
uses the shared reference selector to manage those grants; list, detail,
|
||||
history, timeline, and update paths all apply the same fail-closed ACL.
|
||||
surfaces. A case can remain tenant-visible or become restricted. Restricted
|
||||
access requires a current custodian, explicit subject, or assignment-derived
|
||||
grant whose permission and exact allowed purpose both match the operation.
|
||||
`cases.casework` is the interactive default and `cases.search` is reserved for
|
||||
Search rechecks. The detail surface uses the shared reference selector to
|
||||
manage grants and purposes; list, detail, history, timeline, update, evidence,
|
||||
Decision, Search, and Records-source paths apply the same fail-closed decision.
|
||||
Current grants also govern historical revision reads.
|
||||
|
||||
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,
|
||||
including their bounded allowed purposes,
|
||||
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.
|
||||
|
||||
+92
-12
@@ -88,18 +88,21 @@ The persistent backend provides:
|
||||
- replay-safe case timeline events carrying the institutional context and a
|
||||
stable event/audit reference
|
||||
- tenant-level resource ACL and tenant summary providers
|
||||
- tenant-wide or restricted object access with explicit, revisioned grants
|
||||
- tenant-wide or restricted object access with purpose-bound, revisioned grant snapshots
|
||||
- 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
|
||||
OCC-guarded title/status/access revisions with an explicit change reason.
|
||||
Restricted cases are visible to their creator, case administrators, explicit
|
||||
user/group grants, and matching function, function-assignment, or
|
||||
organization-unit assignments. The same decision filters list, detail,
|
||||
history, timeline, and update operations so an inaccessible identifier does
|
||||
not disclose case existence. API reads and writes are tenant-bound, and
|
||||
create/update/assign/close/share/catalog operations have separate permissions.
|
||||
Restricted cases require a current custodian, explicit subject, or matching
|
||||
function, function-assignment, or organization-unit grant. The grant must imply
|
||||
the requested permission and contain the exact declared purpose. The same
|
||||
decision filters list, detail, history, timeline, update, evidence, Decision,
|
||||
Search, and Records-source operations so an inaccessible identifier does not
|
||||
disclose case existence. Current grants govern historical revisions. API reads
|
||||
and writes are tenant-bound, and create/update/assign/close/share/catalog
|
||||
operations have separate permissions.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -113,9 +116,14 @@ Implemented scopes:
|
||||
- `cases:case:share`
|
||||
- `cases:case:admin`
|
||||
|
||||
Access decisions combine tenant permissions, creator/administrative authority,
|
||||
case assignments, and explicit case shares. Restricted access is versioned
|
||||
with the case record so its history remains reconstructable.
|
||||
Access decisions combine tenant permissions with current purpose-bound
|
||||
custodian, assignment-derived, and explicit subject grants. Creating or newly
|
||||
restricting a case establishes a custodian grant for the attributable actor.
|
||||
`cases.casework` is the stable interactive default; Search rechecks use only
|
||||
`cases.search`; Records filing supplies its declared purpose. Assignment-derived
|
||||
grants admit `cases.casework`, `cases.search`, and `cases.records`. Each accepted
|
||||
timeline event stores the evaluated purpose. Restricted access snapshots remain
|
||||
versioned with the case record while current grant state governs disclosure.
|
||||
|
||||
## Data Model Sketch
|
||||
|
||||
@@ -159,13 +167,62 @@ 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 for the caller-declared purpose, 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 using the fixed `cases.search`
|
||||
purpose before disclosure. Search links preserve that purpose in the Case URL.
|
||||
|
||||
## Information governance adoption
|
||||
|
||||
Cases enforces temporal browsing for immutable case revisions while always
|
||||
using current authorization for historical reads. It enforces exact-purpose
|
||||
access for restricted cases and records the evaluated purpose in lifecycle
|
||||
evidence. Governed context validates and persists tenant, service, party,
|
||||
organization, function, acting assignment/delegation, mandate, jurisdiction,
|
||||
legal-basis, evidence, Decision, and record references at their owning
|
||||
boundaries.
|
||||
|
||||
Retention remains contract-only. Cases preserves immutable revisions and
|
||||
timeline evidence and publishes DSAR retention/manual-review consequences, but
|
||||
does not yet apply configurable schedules, legal holds, appraisal, or
|
||||
disposition gates. Those controls must be implemented through the Records
|
||||
boundary or a future explicit Cases retention policy before retention can be
|
||||
claimed as enforced.
|
||||
|
||||
## WebUI
|
||||
|
||||
Initial route contributions:
|
||||
@@ -173,8 +230,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 +252,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 and matching
|
||||
account, identity, or membership 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.25",
|
||||
"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.15"
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN administrative case context module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.15"]
|
||||
dependencies = ["govoplan-core>=0.1.46"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -140,6 +140,9 @@ class CaseAccessGrant(Base, TimestampMixin):
|
||||
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
allowed_purposes: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -163,6 +166,9 @@ class CaseTimelineEntry(Base, TimestampMixin):
|
||||
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
purpose: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, default="cases.casework", index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
audit_event_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
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 DEFAULT_CASE_ACCESS_PURPOSE, 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
|
||||
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE
|
||||
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,
|
||||
purpose=command.purpose,
|
||||
)
|
||||
current = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
purpose=command.purpose,
|
||||
)
|
||||
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",
|
||||
purpose=command.purpose,
|
||||
):
|
||||
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}",
|
||||
purpose=command.purpose,
|
||||
)
|
||||
return CaseDecisionResult(case=linked, decision=persisted)
|
||||
|
||||
def linked(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
purpose: str | None = None,
|
||||
) -> tuple[FormalDecision, ...]:
|
||||
case = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
purpose=purpose,
|
||||
)
|
||||
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"),
|
||||
(command.purpose, "Case access purpose"),
|
||||
):
|
||||
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",
|
||||
]
|
||||
@@ -17,15 +17,19 @@ CASE_ACCESS_SUBJECT_KINDS = frozenset(
|
||||
{
|
||||
"account",
|
||||
"identity",
|
||||
"membership",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"organization_unit",
|
||||
"service_account",
|
||||
"delegation",
|
||||
}
|
||||
)
|
||||
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
|
||||
DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework"
|
||||
MAX_CASE_ACCESS_PURPOSES = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -33,6 +37,7 @@ class CaseGrant:
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
permissions: tuple[str, ...] = ("read",)
|
||||
allowed_purposes: tuple[str, ...] = (DEFAULT_CASE_ACCESS_PURPOSE,)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
|
||||
@@ -43,12 +48,29 @@ class CaseGrant:
|
||||
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
|
||||
raise InstitutionalContextError("Case access permissions are invalid.")
|
||||
object.__setattr__(self, "permissions", normalized)
|
||||
purposes = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip() for value in self.allowed_purposes if str(value).strip()
|
||||
)
|
||||
)
|
||||
if not purposes:
|
||||
raise InstitutionalContextError(
|
||||
"Case access grants require at least one allowed purpose."
|
||||
)
|
||||
if len(purposes) > MAX_CASE_ACCESS_PURPOSES or any(
|
||||
len(value) > 255 for value in purposes
|
||||
):
|
||||
raise InstitutionalContextError(
|
||||
"Case access grant purposes exceed the supported bounds."
|
||||
)
|
||||
object.__setattr__(self, "allowed_purposes", purposes)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"subject_kind": self.subject_kind,
|
||||
"subject_id": self.subject_id,
|
||||
"permissions": list(self.permissions),
|
||||
"allowed_purposes": list(self.allowed_purposes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -58,10 +80,18 @@ class CaseGrant:
|
||||
raise InstitutionalContextError(
|
||||
"Case access permissions must be a list."
|
||||
)
|
||||
raw_purposes = value.get(
|
||||
"allowed_purposes", (DEFAULT_CASE_ACCESS_PURPOSE,)
|
||||
)
|
||||
if not isinstance(raw_purposes, (list, tuple)):
|
||||
raise InstitutionalContextError(
|
||||
"Case access purposes must be a list."
|
||||
)
|
||||
return cls(
|
||||
subject_kind=_text(value, "subject_kind"),
|
||||
subject_id=_text(value, "subject_id"),
|
||||
permissions=tuple(str(item) for item in raw_permissions),
|
||||
allowed_purposes=tuple(str(item) for item in raw_purposes),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
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),
|
||||
("membership", selectors.membership_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)
|
||||
or (
|
||||
row.subject_kind == "membership"
|
||||
and row.subject_id == selectors.membership_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]],
|
||||
"allowed_purposes": [
|
||||
str(value)[:255] for value in row.allowed_purposes[:100]
|
||||
],
|
||||
"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),
|
||||
"purpose": _bounded_text(row.purpose, 255),
|
||||
"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,247 @@
|
||||
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 DEFAULT_CASE_ACCESS_PURPOSE, 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,
|
||||
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
purpose=purpose,
|
||||
)
|
||||
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,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
purpose=purpose,
|
||||
)
|
||||
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,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
purpose: str,
|
||||
) -> CaseRecord:
|
||||
base = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=expected_revision,
|
||||
purpose=purpose,
|
||||
)
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'cases.reference.lifecycle-access-and-evidence': {'consequence_classes': {'change_access': 'Beifügen '
|
||||
'eines '
|
||||
'bestätigten '
|
||||
'Ereignisses '
|
||||
'hinsichtlich '
|
||||
'der '
|
||||
'Sichtbarkeit/der '
|
||||
'Überarbeitung '
|
||||
'der '
|
||||
'Finanzhilfe '
|
||||
'und '
|
||||
'des '
|
||||
'Zeitplans',
|
||||
'close_case': 'Anfügen '
|
||||
'einer '
|
||||
'Terminalrevision '
|
||||
'nach '
|
||||
'Close-Scope-Autorisierung',
|
||||
'update_case': 'Anfügen '
|
||||
'eines '
|
||||
'OCC-geschützten '
|
||||
'unveränderlichen '
|
||||
'Revisions- '
|
||||
'und '
|
||||
'Zeitleistenereignisses'}},
|
||||
'cases.workflow.file-exact-revision': {'outcome': 'Der eAkte behält eine genaue '
|
||||
'Fall-Snapshot-Referenz, während Cases die '
|
||||
'Autorität behält.',
|
||||
'prerequisites': ['Sie können derzeit den Fall lesen und '
|
||||
'Elemente in den Zieldatensatz eingeben.',
|
||||
'Die genaue Revision des '
|
||||
'unveränderlichen Falles wurde '
|
||||
'ermittelt.'],
|
||||
'steps': ['Öffnen Sie den Fall und identifizieren Sie die '
|
||||
'Revision, die den Beweiszustand darstellt.',
|
||||
'Wählen Sie den Zieldatensatz und geben Sie den '
|
||||
'Zugangszweck und den Anmeldegrund an.',
|
||||
'Bestätigen Sie die Einreichung; Cases überprüft '
|
||||
'den aktuellen Zugriff erneut und löst die '
|
||||
'genaue Überarbeitung.',
|
||||
'Überprüfen Sie die Fallrevision und Snapshot '
|
||||
'Digest in der Datensatzchronologie.']},
|
||||
'cases.workflow.link-owner-references': {'consequence_classes': {'link_owner_reference': 'Eine '
|
||||
'unveränderliche '
|
||||
'Fallrevision, '
|
||||
'die nur '
|
||||
'die '
|
||||
'genaue '
|
||||
'typisierte '
|
||||
'Eigentümerreferenz '
|
||||
'enthält.',
|
||||
'unlink_owner_reference': 'Hängt '
|
||||
'eine '
|
||||
'unveränderliche '
|
||||
'Case-Revision '
|
||||
'an '
|
||||
'und '
|
||||
'lässt '
|
||||
'das '
|
||||
'Owner-Objekt '
|
||||
'unverändert.'}},
|
||||
'cases.workflow.record-formal-decision': {'outcome': 'Das formale Ergebnis ist '
|
||||
'Entscheidungsbesitz und exakte Version aus '
|
||||
'dem Fall verknüpft.',
|
||||
'prerequisites': ['Der Fall hat eine genaue '
|
||||
'verantwortliche Organisation, '
|
||||
'Funktion, Nachweise und rechtliche '
|
||||
'Grundlage Version.',
|
||||
'Eine aktive Handlungszuweisung und '
|
||||
'genau ein wirksames Mandat '
|
||||
'autorisieren den Entscheidungstyp.'],
|
||||
'steps': ['Öffnen Sie den Fall und wählen Sie die '
|
||||
'entsprechende Handlungszuweisung in der '
|
||||
'Titelleiste aus.',
|
||||
'Notieren Sie das operative Ergebnis, die '
|
||||
'Begründung, die optionalen Bedingungen, die '
|
||||
'effektive Zeit und den Änderungsgrund.',
|
||||
'Bestätigen Sie die genauen Entscheidungs- '
|
||||
'und Fallrevisionen in der Fallzeitleiste.',
|
||||
'Wenn Records aktiviert ist, archivieren Sie '
|
||||
'die genaue Entscheidungs- oder Fallrevision '
|
||||
'im Zielort eAkte.']},
|
||||
'cases.workflow.ticket-escalation': {'consequence_classes': {'create_case': 'Erstellt ein '
|
||||
'Fall-eigenes '
|
||||
'formales Verfahren '
|
||||
'und gibt seine '
|
||||
'stabile Referenz '
|
||||
'zurück.',
|
||||
'preserve_ticket': 'Konvertiert, '
|
||||
'kopiert oder '
|
||||
'verändert den '
|
||||
'Ticketverlauf '
|
||||
'nicht.'}}}
|
||||
@@ -1,14 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_cases.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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.information_governance import (
|
||||
InformationGovernanceDimension,
|
||||
ModuleInformationGovernance,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -20,9 +33,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 +50,10 @@ 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 +66,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.15"
|
||||
MODULE_VERSION = "0.1.25"
|
||||
READ_SCOPE = "cases:case:read"
|
||||
CREATE_SCOPE = "cases:case:create"
|
||||
UPDATE_SCOPE = "cases:case:update"
|
||||
@@ -79,6 +105,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,23 +166,60 @@ 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."),
|
||||
_permission(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
|
||||
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
|
||||
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
|
||||
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
|
||||
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
|
||||
_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.",
|
||||
),
|
||||
_permission(
|
||||
UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."
|
||||
),
|
||||
_permission(
|
||||
ASSIGN_SCOPE,
|
||||
"Assign cases",
|
||||
"Change stable function, assignment, or work-item references on a case.",
|
||||
),
|
||||
_permission(
|
||||
CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."
|
||||
),
|
||||
_permission(
|
||||
SHARE_SCOPE,
|
||||
"Share cases",
|
||||
"Restrict a case and grant object-level access to selected principals.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="case_manager",
|
||||
name="Case manager",
|
||||
description="Create, assign, update, and close tenant cases.",
|
||||
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
|
||||
permissions=(
|
||||
READ_SCOPE,
|
||||
CREATE_SCOPE,
|
||||
UPDATE_SCOPE,
|
||||
ASSIGN_SCOPE,
|
||||
CLOSE_SCOPE,
|
||||
SHARE_SCOPE,
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="case_reader",
|
||||
@@ -159,7 +231,15 @@ manifest = ModuleManifest(
|
||||
slug="case_administrator",
|
||||
name="Case administrator",
|
||||
description="Configure case types and statuses and manage cases.",
|
||||
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
|
||||
permissions=(
|
||||
READ_SCOPE,
|
||||
CREATE_SCOPE,
|
||||
UPDATE_SCOPE,
|
||||
ASSIGN_SCOPE,
|
||||
CLOSE_SCOPE,
|
||||
SHARE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
),
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
@@ -198,6 +278,21 @@ 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 +371,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 +386,52 @@ 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="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 +454,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 +498,402 @@ 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",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Exakte Files-, Mail- und Campaign-Verweise mit einem Vorgang verknüpfen",
|
||||
"summary": (
|
||||
"Einen vom zuständigen Modul autorisierten Verweis hinzufügen oder entfernen, ohne dessen Inhalt nach Cases zu kopieren."
|
||||
),
|
||||
"body": (
|
||||
"Für einen aktiven Vorgang können Files, Mail und Campaigns über den versionierten Schnellzugriff-Ergebnisvertrag eine "
|
||||
"exakte mandantengebundene Auswahl zurückgeben. Cases akzeptiert nur unterstützte Kombinationen aus zuständigem Modul "
|
||||
"und Verweisart, den passenden Mandanten, eine festgelegte Version und die kanonische lokale Route des zuständigen Moduls. "
|
||||
"Verknüpfen und Entfernen prüfen die Änderungsberechtigung für den Vorgang erneut, verwenden erwartete Revision und "
|
||||
"Idempotenz und ergänzen eine unveränderliche Vorgangshistorie. Cases speichert nur typisierte Modulkennung, exakte "
|
||||
"Version und Modulroute; Dateibytes, Nachrichtenkopf oder -inhalt, Empfangende, Campaign-Inhalt oder eine frühere "
|
||||
"Zugriffsentscheidung werden nie kopiert. Das Öffnen wechselt stets in das zuständige Modul, das den aktuellen Zugriff "
|
||||
"erneut prüft. Wird dieses Modul deaktiviert oder der Quellzugriff später entzogen, behält der Vorgang den historischen "
|
||||
"Verweis, legt aber keinen Quellinhalt offen. Das Entfernen nimmt nur den Verweis aus der nächsten Vorgangsrevision und "
|
||||
"löscht niemals das Quellobjekt. Ungespeicherte Vorgangsänderungen müssen vor dem Verknüpfen eines Schnellzugriff-Ergebnisses "
|
||||
"gespeichert oder verworfen werden."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"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",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfragen für Vorgänge",
|
||||
"summary": (
|
||||
"Gesteuerte Vorgangstatsachen ausgeben, ohne die antragstellende Identität zu erraten oder undurchsichtige "
|
||||
"Vorgangsdaten offenzulegen."
|
||||
),
|
||||
"body": (
|
||||
"Der DSAR-Provider von Cases gleicht im exakten Mandanten Konten-, Identitäts- und Mitgliedschaftszuordnungen, "
|
||||
"Zugriffsfreigaben für Konten oder Identitäten und ausdrückliche Cases-Verweise ab. Wird ein kanonischer Selektor mit "
|
||||
"einem direkten Verweis kombiniert, muss eine Cases-eigene Beziehung beide bestätigen; andernfalls schließt die Suche "
|
||||
"sicher. Direkte Vorgangs- und Revisionsverweise exportieren eine typisierte, minimierte Lebenszyklusprojektion. Rohe "
|
||||
"Snapshots, Metadaten, Suchtext, Freitext-Änderungsgründe, Timeline-Nutzdaten und -Zusammenfassungen, Nachweiskennungen, "
|
||||
"Anfrage-Digests, Idempotenzschlüssel, Auditkennungen und unbeteiligte Zugriffsbetroffene sind ausgeschlossen. "
|
||||
"Unveränderliche Vorgangskennungen, Revisionen, Timelines und Zuordnungen werden aufbewahrt. Aktuelle offene Vorgänge und "
|
||||
"aktive Zugriffsmerkmale erfordern eine berechtigte manuelle Prüfung im normalen Vorgangs-/Zugriffslebenszyklus; eine "
|
||||
"automatische Löschung wird nicht angeboten. Die Verbindung zwischen antragstellender Person und Identität gehört Parties; "
|
||||
"Cases leitet sie daher nicht aus Beteiligtenkennungen ab."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
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",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Förmliche Entscheidung aus einem Vorgang festhalten",
|
||||
"summary": "Handlungsbefugnis erneut prüfen und eine exakte Decision-Revision in der Vorgangshistorie bewahren.",
|
||||
"body": (
|
||||
"Die Aktion in der Vorgangsdetailansicht akzeptiert nur Tenor, Begründung, Bedingungen, Wirksamkeitszeitpunkt und "
|
||||
"Änderungsgrund. Cases löst die exakte Vorgangsrevision auf, prüft den objektbezogenen Änderungszugriff, ermittelt über "
|
||||
"Access die aktuelle Handlungszuweisung und verlangt genau ein wirksames Mandat für cases.formal_decision und den gewählten "
|
||||
"Decision-Typ. Anschließend wird das Ergebnis über das optionale Decisions-Register aufgezeichnet und die exakte "
|
||||
"Decision-Revision in derselben Datenbanktransaktion mit einer neuen Vorgangsrevision verknüpft. Eine Wiederholung "
|
||||
"verwendet eine deterministische Decision-Kennung; abweichende Nutzdaten schließen sicher."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"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",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Exakte Vorgangsrevision in eine eAkte ablegen",
|
||||
"summary": "Einen rekonstruierbaren Vorgangs-Snapshot in Records bewahren, ohne die Zuständigkeit von Cases zu verlagern.",
|
||||
"body": (
|
||||
"Ist Records aktiviert, löst Cases eine exakte unveränderliche Vorgangsrevision erst auf, nachdem aktuelle "
|
||||
"Vorgangsberechtigung und objektbezogener Zugriff geprüft wurden. Der abgelegte Verweis enthält Vorgangsnummer, "
|
||||
"Lebenszyklusstatus, dargestelltes Gültigkeitsintervall, Aufzeichnungszeitpunkt und einen SHA-256-Digest des kanonischen "
|
||||
"Snapshots. Cases bleibt für den Vorgangslebenszyklus maßgeblich."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"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.authorized-pagination",
|
||||
title="Authorized case pages and exact totals",
|
||||
summary="Filter current case access in the database before counting and paging.",
|
||||
body=(
|
||||
"Case lists use one current tenant/access/purpose predicate for both exact totals and pages of at most "
|
||||
"200 records. Restricted access requires an active matching subject grant, sufficient permission, and "
|
||||
"an exact declared purpose; general administrator scopes do not bypass this rule. Hidden cases never "
|
||||
"consume page slots or enter totals. The database evaluates grant existence without loading all tenant "
|
||||
"case IDs or grants into the application. Pages retain recorded-time and stable case-ID ordering; each "
|
||||
"request rechecks current grants and is not a snapshot across concurrent changes. Exact JSON permission "
|
||||
"and purpose predicates support SQLite and PostgreSQL, reject malformed element types, and fail closed "
|
||||
"on unsupported database dialects. Historical reads still use current access."
|
||||
),
|
||||
layer="always", documentation_types=("user", "admin"),
|
||||
audience=("user", "case_manager", "module_admin"), order=17,
|
||||
translations={"de": {
|
||||
"title": "Berechtigte Vorgangsseiten und exakte Gesamtzahlen",
|
||||
"summary": "Aktuellen Vorgangszugriff vor Zählung und Seitenauswahl in der Datenbank filtern.",
|
||||
"body": (
|
||||
"Vorgangslisten verwenden denselben aktuellen Mandanten-/Zugriffs-/Zweckfilter für exakte Gesamtzahlen "
|
||||
"und Seiten mit höchstens 200 Datensätzen. Eingeschränkter Zugriff verlangt eine aktive passende "
|
||||
"Subjektfreigabe, ausreichende Rechte und einen exakt angegebenen Zweck; allgemeine Administratorrechte "
|
||||
"umgehen diese Regel nicht. Verborgene Vorgänge belegen keine Seitenplätze und zählen nicht mit. "
|
||||
"Die Datenbank prüft Freigaben, ohne alle Vorgangskennungen oder Freigaben des Mandanten in die Anwendung "
|
||||
"zu laden. Die Sortierung nach Erfassungszeit und stabiler Vorgangskennung bleibt bestehen; jede Anfrage "
|
||||
"prüft aktuelle Freigaben erneut und ist kein Snapshot über parallele Änderungen. Exakte JSON-Rechte- "
|
||||
"und Zweckfilter unterstützen SQLite und PostgreSQL, verwerfen fehlerhafte Elementtypen und lehnen "
|
||||
"nicht unterstützte Datenbankdialekte sicher ab. Historische Abrufe verwenden weiterhin aktuelle Rechte."
|
||||
),
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.governance.purpose-bound-access",
|
||||
title="Purpose-bound access to restricted cases",
|
||||
summary="Require a current subject grant, sufficient action, and one exact declared purpose before disclosing or changing a restricted case.",
|
||||
body=(
|
||||
"Tenant-visible cases continue to follow the Cases read permission. For a restricted case, every list, detail, history, timeline, update, share, evidence, Decision, Search, and Records-source path evaluates current grants against the acting account, membership, group, role, function, assignment, organization unit, service account, or delegation. The requested action and exact purpose must both match; missing or different purposes fail closed. cases.casework is the stable interactive default, cases.search is used only by Search authorization rechecks, and a Records filing request supplies its own declared purpose. Creating or newly restricting a case writes a purpose-bound custodian grant for the attributable actor. Assignment-derived grants permit cases.casework, cases.search, and cases.records. Explicit grants list their purposes separately and can be changed through the confirmed access dialog. Current grants always govern historical revision reads, so removed access cannot be recovered by selecting an older revision. Every accepted lifecycle event records the evaluated purpose. Retention schedules, legal holds, and disposition gates are not yet Cases-owned and remain a documented limitation."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "case_manager", "module_admin", "auditor"),
|
||||
related_modules=("search", "records", "access"),
|
||||
order=16,
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Cases concept",
|
||||
href="govoplan-cases/docs/CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zweckgebundener Zugriff auf eingeschränkte Vorgänge",
|
||||
"summary": "Vor Offenlegung oder Änderung eines eingeschränkten Vorgangs eine aktuelle Subjektfreigabe, ausreichende Aktion und einen exakt angegebenen Zweck verlangen.",
|
||||
"body": (
|
||||
"Mandantenweit sichtbare Vorgänge folgen weiterhin der Leseberechtigung von Cases. Bei einem eingeschränkten Vorgang prüfen Listen-, Detail-, Historien-, Timeline-, Änderungs-, Freigabe-, Nachweis-, Decision-, Search- und Records-Quellpfade aktuelle Freigaben gegen das handelnde Konto, die Mitgliedschaft, Gruppe, Rolle, Funktion, Funktionszuweisung, Organisationseinheit, das Dienstkonto oder die Delegation. Angeforderte Aktion und exakter Zweck müssen übereinstimmen; fehlende oder abweichende Zwecke schließen sicher. cases.casework ist der stabile interaktive Standard, cases.search wird ausschließlich für erneute Search-Autorisierungen verwendet und eine Records-Ablage übergibt ihren eigenen angegebenen Zweck. Beim Anlegen oder erstmaligen Einschränken entsteht eine zweckgebundene Custodian-Freigabe für die zurechenbare handelnde Person. Aus Zuweisungen abgeleitete Freigaben erlauben cases.casework, cases.search und cases.records. Ausdrückliche Freigaben führen ihre Zwecke getrennt und werden im bestätigten Zugriffsdialog geändert. Aktuelle Freigaben gelten stets auch für historische Revisionsabrufe; entzogener Zugriff kann daher nicht über eine ältere Revision wiederhergestellt werden. Jedes akzeptierte Lebenszyklusereignis hält den ausgewerteten Zweck fest. Aufbewahrungspläne, Legal Holds und Aussonderungssperren gehören noch nicht Cases und bleiben als Einschränkung dokumentiert."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"cases.page.catalogue",
|
||||
"cases.detail.access",
|
||||
"cases.field.access-purpose",
|
||||
"cases.field.allowed-purposes",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.institutional-context",
|
||||
title="Case institutional context",
|
||||
@@ -357,7 +904,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"),
|
||||
@@ -369,10 +919,28 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Institutioneller Kontext eines Vorgangs",
|
||||
"summary": (
|
||||
"Cases bewahrt Service- und Beteiligtenverweise, ohne institutionelle Definitionen oder Betroffenenstammdaten zu übernehmen."
|
||||
),
|
||||
"body": (
|
||||
"Vorgangstypen und -status sind Mandantenkonfiguration. Jede Anlage oder Änderung schreibt eine unveränderliche, durch "
|
||||
"optimistische Nebenläufigkeitskontrolle geschützte Revision und ein wiederholungssicheres Timeline-Ereignis. Die "
|
||||
"Service-Aufnahme hält exakte Bindungen an Service, Mandat, Zuständigkeit, Rechtsgrundlage, Formular, Workflow und Ergebnis "
|
||||
"fest. Verfahrensbeteiligte stammen von einem optionalen Provider oder einer begrenzten Cases-Kompatibilitätsprojektion. "
|
||||
"Zuweisungs-, Nachweis-, Decision- und Aktenverweise bleiben stabile Referenzen im Eigentum ihrer Quellmodule. Ist Search "
|
||||
"aktiviert, liefert Cases eine neu aufbaubare reine Metadatenprojektion. Nachweise und geschützte Decision-Inhalte sind "
|
||||
"ausgeschlossen; jeder Treffer wird vor der Offenlegung erneut gegen den aktuellen Vorgangszugriff geprüft."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"cases.list",
|
||||
"cases.detail",
|
||||
"cases.search.result",
|
||||
"cases.state.read-only",
|
||||
"cases.state.restricted",
|
||||
],
|
||||
@@ -383,13 +951,15 @@ manifest = ModuleManifest(
|
||||
title="Case lifecycle, access, and evidence reference",
|
||||
summary="Explains revision, status, access, and reference fields together with their durable consequences.",
|
||||
body=(
|
||||
"Documentation books sit beside the Cases collection heading, the selected case title, and "
|
||||
"the access or decision dialog title; field help stays with its label. "
|
||||
"Title and status changes append an immutable case revision guarded by the "
|
||||
"expected revision and a stable idempotency key. Every accepted change also "
|
||||
"appends a timeline entry with actor, time, and change reason; a terminal "
|
||||
"status additionally requires the case-close permission. Case visibility is "
|
||||
"tenant-wide or restricted. Restricted cases remain visible only through "
|
||||
"administrative authority, assignment or unit context, creator authority, or "
|
||||
"an explicit account/group grant. Access changes append another immutable "
|
||||
"a current purpose-bound custodian, assignment/unit context, or explicit "
|
||||
"subject grant. Permission and exact allowed purpose must both match. Access changes append another immutable "
|
||||
"revision and require confirmation. Service, party, assignment, Decision, and "
|
||||
"record references identify provider-owned objects; Cases preserves their "
|
||||
"stable identifiers and versions without copying or silently changing them."
|
||||
@@ -404,7 +974,29 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Referenz für Vorgangslebenszyklus, Zugriff und Nachweise",
|
||||
"summary": (
|
||||
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
|
||||
),
|
||||
"body": (
|
||||
"Dokumentationsbücher stehen neben der Überschrift der Vorgangsliste, dem Titel des "
|
||||
"ausgewählten Vorgangs und dem Titel des Zugriffs- oder Entscheidungsdialogs; Feldhilfe "
|
||||
"bleibt bei der Feldbezeichnung. "
|
||||
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
|
||||
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
|
||||
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
|
||||
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur über eine aktuelle zweckgebundene "
|
||||
"Custodian-, Zuweisungs-/Einheiten- oder ausdrückliche Subjektfreigabe sichtbar; Berechtigung und exakter erlaubter Zweck müssen übereinstimmen. "
|
||||
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
|
||||
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
|
||||
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"cases.field.title",
|
||||
"cases.field.status",
|
||||
@@ -421,6 +1013,47 @@ manifest = ModuleManifest(
|
||||
},
|
||||
),
|
||||
),
|
||||
information_governance=ModuleInformationGovernance(
|
||||
temporal_browsing=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=("case", "case_revision", "case_timeline_event"),
|
||||
evidence=(
|
||||
"src/govoplan_cases/backend/service.py",
|
||||
"tests/test_case_lifecycle.py",
|
||||
),
|
||||
),
|
||||
purpose_aware_access=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=("case", "case_access_grant", "case_timeline_event"),
|
||||
evidence=(
|
||||
"src/govoplan_cases/backend/service.py",
|
||||
"src/govoplan_cases/backend/search_source.py",
|
||||
"src/govoplan_cases/backend/record_source.py",
|
||||
"tests/test_case_lifecycle.py",
|
||||
"tests/test_search_source.py",
|
||||
"tests/test_record_source.py",
|
||||
),
|
||||
),
|
||||
retention=InformationGovernanceDimension(
|
||||
adoption="contract_only",
|
||||
object_types=("case", "case_revision", "case_timeline_event"),
|
||||
limitation=(
|
||||
"Cases preserves immutable history and DSAR retention evidence but does not yet apply configurable schedules, legal holds, appraisal, or disposition gates."
|
||||
),
|
||||
),
|
||||
institutional_context=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=("case", "case_revision", "case_timeline_event"),
|
||||
evidence=(
|
||||
"src/govoplan_cases/backend/domain.py",
|
||||
"src/govoplan_cases/backend/service.py",
|
||||
"src/govoplan_cases/backend/decision_path.py",
|
||||
"tests/test_case_lifecycle.py",
|
||||
"tests/test_institutional_consumers.py",
|
||||
"tests/test_decision_path.py",
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="human_work_procedure",
|
||||
kind="domain",
|
||||
@@ -459,5 +1092,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""v0.1.23 purpose-bound case access.
|
||||
|
||||
Revision ID: a7c4e2f9b1d6
|
||||
Revises: f6d3a8b1c4e7
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a7c4e2f9b1d6"
|
||||
down_revision = "f6d3a8b1c4e7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"case_access_grants",
|
||||
sa.Column(
|
||||
"allowed_purposes",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default='["cases.casework"]',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"case_timeline_entries",
|
||||
sa.Column(
|
||||
"purpose",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
server_default="cases.casework",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_case_timeline_entries_purpose"),
|
||||
"case_timeline_entries",
|
||||
["purpose"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_case_timeline_entries_purpose"),
|
||||
table_name="case_timeline_entries",
|
||||
)
|
||||
op.drop_column("case_timeline_entries", "purpose")
|
||||
op.drop_column("case_access_grants", "allowed_purposes")
|
||||
@@ -0,0 +1,122 @@
|
||||
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",
|
||||
purpose=purpose,
|
||||
):
|
||||
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='')}"
|
||||
f"?purpose={quote(purpose, 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
|
||||
@@ -156,6 +174,7 @@ def api_list_cases(
|
||||
query: str = "",
|
||||
status_key: list[str] | None = Query(default=None),
|
||||
case_type_key: list[str] | None = Query(default=None),
|
||||
purpose: str | None = Query(default=None, max_length=255),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
@@ -169,6 +188,7 @@ def api_list_cases(
|
||||
query=query,
|
||||
status_keys=status_key,
|
||||
case_type_keys=case_type_key,
|
||||
purpose=purpose,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -198,6 +218,7 @@ def api_create_case(
|
||||
principal,
|
||||
record=record,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
purpose=payload.purpose,
|
||||
)
|
||||
session.commit()
|
||||
except (CaseStoreError, InstitutionalContextError) as exc:
|
||||
@@ -206,15 +227,97 @@ 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,
|
||||
purpose: str | None = Query(default=None, max_length=255),
|
||||
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,
|
||||
purpose=purpose,
|
||||
)
|
||||
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,
|
||||
purpose=payload.purpose,
|
||||
),
|
||||
)
|
||||
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,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
purpose: str | None = Query(default=None, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, READ_SCOPE)
|
||||
item = get_case(session, principal, case_id=case_id, revision=revision)
|
||||
item = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=revision,
|
||||
purpose=purpose,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
return item.to_dict()
|
||||
@@ -252,6 +355,75 @@ def api_update_case(
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
purpose=payload.purpose,
|
||||
)
|
||||
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", 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,
|
||||
purpose=payload.purpose,
|
||||
)
|
||||
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,
|
||||
purpose=payload.purpose,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
@@ -276,6 +448,7 @@ def api_case_share_target_options(
|
||||
selected: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = None,
|
||||
purpose: str = Query(default="cases.casework", min_length=1, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ReferenceOptionListResponse:
|
||||
@@ -285,6 +458,7 @@ def api_case_share_target_options(
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="share",
|
||||
purpose=purpose,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Case share access is denied")
|
||||
try:
|
||||
@@ -314,6 +488,7 @@ def api_case_share_target_options(
|
||||
def api_case_history(
|
||||
case_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
purpose: str | None = Query(default=None, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> CaseHistoryResponse:
|
||||
@@ -326,6 +501,7 @@ def api_case_history(
|
||||
principal,
|
||||
case_id=case_id,
|
||||
limit=limit,
|
||||
purpose=purpose,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -335,6 +511,7 @@ def api_case_history(
|
||||
def api_case_timeline(
|
||||
case_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
purpose: str | None = Query(default=None, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> CaseTimelineResponse:
|
||||
@@ -346,6 +523,7 @@ def api_case_timeline(
|
||||
principal,
|
||||
case_id=case_id,
|
||||
limit=limit,
|
||||
purpose=purpose,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -357,6 +535,7 @@ def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
|
||||
"recorded_at",
|
||||
"change_reason",
|
||||
"idempotency_key",
|
||||
"purpose",
|
||||
}
|
||||
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
|
||||
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
|
||||
|
||||
@@ -34,6 +34,7 @@ class CaseWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
record: dict[str, Any]
|
||||
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -43,12 +44,14 @@ class CaseGrantRequest(BaseModel):
|
||||
subject_kind: Literal[
|
||||
"account",
|
||||
"identity",
|
||||
"membership",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"organization_unit",
|
||||
"service_account",
|
||||
"delegation",
|
||||
]
|
||||
subject_id: str = Field(min_length=1, max_length=255)
|
||||
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
|
||||
@@ -56,6 +59,11 @@ class CaseGrantRequest(BaseModel):
|
||||
min_length=1,
|
||||
max_length=4,
|
||||
)
|
||||
allowed_purposes: list[str] = Field(
|
||||
default_factory=lambda: ["cases.casework"],
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
|
||||
class CaseUpdateRequest(BaseModel):
|
||||
@@ -65,6 +73,7 @@ class CaseUpdateRequest(BaseModel):
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||
status_key: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
access_mode: Literal["tenant", "restricted"] | None = None
|
||||
@@ -82,6 +91,52 @@ 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)
|
||||
purpose: str = Field(default="cases.casework", 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)
|
||||
purpose: str = Field(default="cases.casework", 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)
|
||||
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
cases: list[dict[str, Any]]
|
||||
total: int
|
||||
@@ -99,6 +154,9 @@ class CaseTimelineResponse(BaseModel):
|
||||
|
||||
__all__ = [
|
||||
"CaseGrantRequest",
|
||||
"CaseDecisionRequest",
|
||||
"CaseEvidenceLinkRequest",
|
||||
"CaseEvidenceUnlinkRequest",
|
||||
"CaseHistoryResponse",
|
||||
"CaseListResponse",
|
||||
"CaseStatusWriteRequest",
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
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"
|
||||
SEARCH_ACCESS_PURPOSE = "cases.search"
|
||||
|
||||
|
||||
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",
|
||||
purpose=SEARCH_ACCESS_PURPOSE,
|
||||
)
|
||||
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='')}"
|
||||
f"?purpose={quote(SEARCH_ACCESS_PURPOSE, 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",
|
||||
"SEARCH_ACCESS_PURPOSE",
|
||||
"create_cases_search_source",
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import uuid
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.principal_helpers import principal_user_first_actor as _principal_actor
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
@@ -24,6 +25,7 @@ from govoplan_core.core.institutional import (
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.db.json_predicates import json_array_contains_string
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
@@ -32,7 +34,11 @@ from govoplan_cases.backend.db.models import (
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||
from govoplan_cases.backend.domain import (
|
||||
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
CaseGrant,
|
||||
CaseRecord,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import CaseIntakePlan
|
||||
|
||||
|
||||
@@ -222,8 +228,10 @@ def create_case(
|
||||
*,
|
||||
record: CaseRecord,
|
||||
idempotency_key: str,
|
||||
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
) -> CaseRecord:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
declared_purpose = _access_purpose(purpose)
|
||||
if record.reference.tenant_id != tenant_id:
|
||||
raise CaseStoreError("Case records cannot cross tenants.")
|
||||
if record.revision != 1:
|
||||
@@ -241,6 +249,7 @@ def create_case(
|
||||
principal,
|
||||
case_id=replay.reference.object_id,
|
||||
permission="read",
|
||||
purpose=declared_purpose,
|
||||
):
|
||||
raise PermissionError("Case replay access is denied.")
|
||||
return replay
|
||||
@@ -274,6 +283,7 @@ def create_case(
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
operation="created",
|
||||
purpose=declared_purpose,
|
||||
)
|
||||
return persisted
|
||||
|
||||
@@ -331,6 +341,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(
|
||||
@@ -351,8 +363,10 @@ def update_case(
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
) -> CaseRecord:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
declared_purpose = _access_purpose(purpose)
|
||||
current_row = _current_row(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -361,7 +375,13 @@ def update_case(
|
||||
)
|
||||
if current_row is None:
|
||||
raise LookupError("Case not found.")
|
||||
if not can_access_case(session, principal, case_id=case_id, permission="update"):
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="update",
|
||||
purpose=declared_purpose,
|
||||
):
|
||||
raise PermissionError("Case update access is denied.")
|
||||
current = _record_from_row(current_row)
|
||||
normalized = _normalized_changes(changes)
|
||||
@@ -381,6 +401,14 @@ def update_case(
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="read",
|
||||
purpose=declared_purpose,
|
||||
):
|
||||
raise PermissionError("Case replay access is denied.")
|
||||
return replay
|
||||
if current.revision != expected_revision:
|
||||
raise CaseStoreError(
|
||||
@@ -459,6 +487,7 @@ def update_case(
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
operation="closed" if terminal and not current.closed_at else "updated",
|
||||
purpose=declared_purpose,
|
||||
)
|
||||
|
||||
|
||||
@@ -468,6 +497,7 @@ def get_case(
|
||||
*,
|
||||
case_id: str,
|
||||
revision: int | None = None,
|
||||
purpose: str | None = None,
|
||||
) -> CaseRecord | None:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
query = session.query(CaseRecordRevision).filter(
|
||||
@@ -484,6 +514,7 @@ def get_case(
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="read",
|
||||
purpose=purpose,
|
||||
):
|
||||
return None
|
||||
return _record_from_row(row) if row is not None else None
|
||||
@@ -496,6 +527,7 @@ def list_cases(
|
||||
query: str = "",
|
||||
status_keys: Sequence[str] | None = None,
|
||||
case_type_keys: Sequence[str] | None = None,
|
||||
purpose: str | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[tuple[CaseRecord, ...], int]:
|
||||
@@ -508,7 +540,7 @@ def list_cases(
|
||||
CaseRecordRevision.tenant_id == tenant_id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
statement = _filter_accessible_cases(statement, principal, permission="read")
|
||||
statement = statement.filter(_case_access_predicate(principal, permission="read", purpose=purpose))
|
||||
if status_keys:
|
||||
statement = statement.filter(
|
||||
CaseRecordRevision.status_key.in_(tuple(status_keys))
|
||||
@@ -541,8 +573,15 @@ def case_history(
|
||||
*,
|
||||
case_id: str,
|
||||
limit: int = 100,
|
||||
purpose: str | None = None,
|
||||
) -> tuple[CaseRecord, ...]:
|
||||
if not can_access_case(session, principal, case_id=case_id, permission="read"):
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="read",
|
||||
purpose=purpose,
|
||||
):
|
||||
return ()
|
||||
if not 1 <= limit <= 200:
|
||||
raise CaseStoreError("Case history limit must be between 1 and 200.")
|
||||
@@ -565,8 +604,15 @@ def case_timeline(
|
||||
*,
|
||||
case_id: str,
|
||||
limit: int = 200,
|
||||
purpose: str | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
if not can_access_case(session, principal, case_id=case_id, permission="read"):
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="read",
|
||||
purpose=purpose,
|
||||
):
|
||||
return ()
|
||||
if not 1 <= limit <= 500:
|
||||
raise CaseStoreError("Case timeline limit must be between 1 and 500.")
|
||||
@@ -593,10 +639,21 @@ class SqlCaseRegistry:
|
||||
if reference.tenant_id != _principal_tenant(principal):
|
||||
raise InstitutionalContextError("Case lookup cannot cross tenants.")
|
||||
revision = int(reference.version) if reference.version else None
|
||||
return get_case(_session(session), principal, case_id=reference.object_id, revision=revision)
|
||||
return get_case(
|
||||
_session(session),
|
||||
principal,
|
||||
case_id=reference.object_id,
|
||||
revision=revision,
|
||||
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
)
|
||||
|
||||
def list_cases(self, session: object, principal: object, *, limit: int = 100) -> Sequence[CaseRecord]:
|
||||
items, _total = list_cases(_session(session), principal, limit=limit)
|
||||
items, _total = list_cases(
|
||||
_session(session),
|
||||
principal,
|
||||
limit=limit,
|
||||
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@@ -606,6 +663,7 @@ def can_access_case(
|
||||
*,
|
||||
case_id: str,
|
||||
permission: str = "read",
|
||||
purpose: str | None = None,
|
||||
) -> bool:
|
||||
if permission not in {"read", "update", "share", "admin"}:
|
||||
raise CaseStoreError("Unsupported case access permission.")
|
||||
@@ -618,87 +676,74 @@ def can_access_case(
|
||||
)
|
||||
if current is None:
|
||||
return False
|
||||
if _has_scope(principal, "cases:case:admin"):
|
||||
return True
|
||||
identity = _identity(session, tenant_id=tenant_id, case_id=case_id)
|
||||
if identity is not None and identity.created_by in _principal_actor_ids(principal):
|
||||
return True
|
||||
if current.access_mode == "tenant":
|
||||
return True
|
||||
if current.access_mode != "restricted":
|
||||
return False
|
||||
declared_purpose = str(purpose or "").strip()
|
||||
if not declared_purpose:
|
||||
return False
|
||||
subjects = _principal_subjects(principal)
|
||||
if not subjects:
|
||||
return False
|
||||
clauses = [
|
||||
and_(
|
||||
CaseAccessGrant.subject_kind == kind,
|
||||
CaseAccessGrant.subject_id == subject_id,
|
||||
)
|
||||
for kind, subject_id in subjects
|
||||
]
|
||||
grants = (
|
||||
session.query(CaseAccessGrant)
|
||||
.filter(
|
||||
CaseAccessGrant.tenant_id == tenant_id,
|
||||
CaseAccessGrant.case_id == case_id,
|
||||
CaseAccessGrant.active.is_(True),
|
||||
or_(*clauses),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return any(
|
||||
_access_permissions_allow(tuple(item.permissions or ()), permission)
|
||||
(item.subject_kind, item.subject_id) in subjects
|
||||
and _access_permissions_allow(tuple(item.permissions or ()), permission)
|
||||
and declared_purpose in tuple(item.allowed_purposes or ())
|
||||
for item in grants
|
||||
)
|
||||
|
||||
|
||||
def _filter_accessible_cases(statement, principal: object, *, permission: str):
|
||||
if _has_scope(principal, "cases:case:admin"):
|
||||
return statement
|
||||
conditions = [CaseRecordRevision.access_mode == "tenant"]
|
||||
actor_ids = _principal_actor_ids(principal)
|
||||
if actor_ids:
|
||||
conditions.append(
|
||||
exists().where(
|
||||
and_(
|
||||
CaseIdentity.tenant_id == CaseRecordRevision.tenant_id,
|
||||
CaseIdentity.case_id == CaseRecordRevision.case_id,
|
||||
CaseIdentity.created_by.in_(actor_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
def _case_access_predicate(
|
||||
principal: object,
|
||||
*,
|
||||
permission: str,
|
||||
purpose: str | None,
|
||||
):
|
||||
"""Current tenant/purpose/grant policy, without materializing tenant IDs."""
|
||||
tenant_visible = CaseRecordRevision.access_mode == "tenant"
|
||||
declared_purpose = str(purpose or "").strip()
|
||||
if not declared_purpose:
|
||||
return tenant_visible
|
||||
subjects = _principal_subjects(principal)
|
||||
if subjects:
|
||||
subject_clause = or_(
|
||||
*(
|
||||
and_(
|
||||
CaseAccessGrant.subject_kind == kind,
|
||||
CaseAccessGrant.subject_id == subject_id,
|
||||
)
|
||||
for kind, subject_id in subjects
|
||||
)
|
||||
)
|
||||
conditions.append(
|
||||
exists().where(
|
||||
and_(
|
||||
CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id,
|
||||
CaseAccessGrant.case_id == CaseRecordRevision.case_id,
|
||||
CaseAccessGrant.active.is_(True),
|
||||
subject_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
if permission != "read":
|
||||
raise CaseStoreError("Case list filtering supports read access only.")
|
||||
return statement.filter(or_(*conditions))
|
||||
if not subjects:
|
||||
return tenant_visible
|
||||
allowed_permissions = ("admin", "read", "update", "share") if permission == "read" else ("admin", permission)
|
||||
grant_matches = exists().where(
|
||||
CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id,
|
||||
CaseAccessGrant.case_id == CaseRecordRevision.case_id,
|
||||
CaseAccessGrant.active.is_(True),
|
||||
or_(*(and_(CaseAccessGrant.subject_kind == kind, CaseAccessGrant.subject_id == subject_id) for kind, subject_id in subjects)),
|
||||
or_(*(json_array_contains_string(CaseAccessGrant.permissions, value) for value in allowed_permissions)),
|
||||
json_array_contains_string(CaseAccessGrant.allowed_purposes, declared_purpose),
|
||||
)
|
||||
return or_(tenant_visible, grant_matches)
|
||||
|
||||
|
||||
def _sync_access_grants(
|
||||
session: Session,
|
||||
principal: object,
|
||||
record: CaseRecord,
|
||||
*,
|
||||
purpose: str,
|
||||
ensure_custodian: bool,
|
||||
) -> None:
|
||||
desired: dict[tuple[str, str, str], tuple[str, ...]] = {
|
||||
(item.subject_kind, item.subject_id, "explicit"): item.permissions
|
||||
desired: dict[
|
||||
tuple[str, str, str], tuple[tuple[str, ...], tuple[str, ...]]
|
||||
] = {
|
||||
(item.subject_kind, item.subject_id, "explicit"): (
|
||||
item.permissions,
|
||||
item.allowed_purposes,
|
||||
)
|
||||
for item in record.access_grants
|
||||
}
|
||||
for reference in record.assignment_refs:
|
||||
@@ -709,8 +754,8 @@ def _sync_access_grants(
|
||||
}:
|
||||
continue
|
||||
desired[(reference.kind, reference.object_id, "assignment")] = (
|
||||
"read",
|
||||
"update",
|
||||
("read", "update"),
|
||||
("cases.casework", "cases.search", "cases.records"),
|
||||
)
|
||||
rows = (
|
||||
session.query(CaseAccessGrant)
|
||||
@@ -723,11 +768,21 @@ def _sync_access_grants(
|
||||
existing = {
|
||||
(item.subject_kind, item.subject_id, item.source): item for item in rows
|
||||
}
|
||||
if ensure_custodian and record.access_mode == "restricted":
|
||||
subject = _custodian_subject(principal)
|
||||
if subject is None:
|
||||
raise CaseStoreError(
|
||||
"Restricted case creation requires an attributable custodian."
|
||||
)
|
||||
desired[(subject[0], subject[1], "custodian")] = (
|
||||
("read", "update", "share", "admin"),
|
||||
(purpose,),
|
||||
)
|
||||
for key, row in existing.items():
|
||||
if key not in desired:
|
||||
if key not in desired and row.source != "custodian":
|
||||
row.active = False
|
||||
row.source_revision = record.revision
|
||||
for key, permissions in desired.items():
|
||||
for key, (permissions, allowed_purposes) in desired.items():
|
||||
row = existing.get(key)
|
||||
if row is None:
|
||||
session.add(
|
||||
@@ -737,6 +792,7 @@ def _sync_access_grants(
|
||||
subject_kind=key[0],
|
||||
subject_id=key[1],
|
||||
permissions=list(permissions),
|
||||
allowed_purposes=list(allowed_purposes),
|
||||
source=key[2],
|
||||
active=True,
|
||||
source_revision=record.revision,
|
||||
@@ -745,6 +801,7 @@ def _sync_access_grants(
|
||||
)
|
||||
else:
|
||||
row.permissions = list(permissions)
|
||||
row.allowed_purposes = list(allowed_purposes)
|
||||
row.active = True
|
||||
row.source_revision = record.revision
|
||||
session.flush()
|
||||
@@ -778,6 +835,7 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
||||
for kind, name in (
|
||||
("account", "account_id"),
|
||||
("identity", "identity_id"),
|
||||
("membership", "membership_id"),
|
||||
("service_account", "service_account_id"),
|
||||
):
|
||||
value = str(getattr(principal, name, None) or "").strip()
|
||||
@@ -789,15 +847,41 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
||||
("function", "function_ids"),
|
||||
("function_assignment", "function_assignment_ids"),
|
||||
("organization_unit", "organization_unit_ids"),
|
||||
("delegation", "delegation_ids"),
|
||||
):
|
||||
subjects.extend(
|
||||
(kind, str(item))
|
||||
for item in (getattr(principal, name, ()) or ())
|
||||
if str(item).strip()
|
||||
)
|
||||
for kind, name in (
|
||||
("function_assignment", "acting_assignment_id"),
|
||||
("delegation", "acting_delegation_id"),
|
||||
("delegation", "delegation_id"),
|
||||
):
|
||||
value = str(getattr(principal, name, None) or "").strip()
|
||||
if value:
|
||||
subjects.append((kind, value))
|
||||
return tuple(dict.fromkeys(subjects))
|
||||
|
||||
|
||||
def _custodian_subject(principal: object) -> tuple[str, str] | None:
|
||||
for kind, name in (
|
||||
("account", "account_id"),
|
||||
("identity", "identity_id"),
|
||||
("membership", "membership_id"),
|
||||
("service_account", "service_account_id"),
|
||||
):
|
||||
value = str(getattr(principal, name, None) or "").strip()
|
||||
if value:
|
||||
return kind, value
|
||||
return None
|
||||
|
||||
|
||||
def _access_purpose(value: object) -> str:
|
||||
return _required_text(value, "Case access purpose", maximum=255)
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
method = getattr(principal, "has", None)
|
||||
if callable(method):
|
||||
@@ -818,6 +902,7 @@ def _record_revision(
|
||||
idempotency_key: str,
|
||||
request_sha256: str,
|
||||
operation: str,
|
||||
purpose: str,
|
||||
) -> CaseRecord:
|
||||
clean_key = _required_text(
|
||||
idempotency_key,
|
||||
@@ -858,6 +943,7 @@ def _record_revision(
|
||||
summary=f"Case {identity.case_number} {operation}.",
|
||||
occurred_at=record.recorded_at,
|
||||
actor_id=_principal_actor(principal),
|
||||
purpose=purpose,
|
||||
idempotency_key=clean_key,
|
||||
request_sha256=request_sha256,
|
||||
audit_event_id=event_id,
|
||||
@@ -868,12 +954,22 @@ def _record_revision(
|
||||
"revision": record.revision,
|
||||
"access_mode": record.access_mode,
|
||||
"access_grant_count": len(record.access_grants),
|
||||
"purpose": purpose,
|
||||
"change_reason": record.change_reason,
|
||||
},
|
||||
)
|
||||
session.add_all((row, timeline))
|
||||
session.flush()
|
||||
_sync_access_grants(session, principal, record)
|
||||
_sync_access_grants(
|
||||
session,
|
||||
principal,
|
||||
record,
|
||||
purpose=purpose,
|
||||
ensure_custodian=(
|
||||
record.access_mode == "restricted"
|
||||
and (current is None or current.access_mode != "restricted")
|
||||
),
|
||||
)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
@@ -1106,6 +1202,7 @@ def _timeline_payload(row: CaseTimelineEntry) -> dict[str, object]:
|
||||
"summary": row.summary,
|
||||
"occurred_at": _datetime_text(row.occurred_at),
|
||||
"actor_id": row.actor_id,
|
||||
"purpose": row.purpose,
|
||||
"audit_event_id": row.audit_event_id,
|
||||
"payload": dict(row.payload or {}),
|
||||
}
|
||||
@@ -1253,20 +1350,6 @@ def _principal_tenant(principal: object) -> str:
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _principal_actor(principal: object) -> str | None:
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not hasattr(value, "query"):
|
||||
raise InstitutionalContextError("Case registry requires a database session.")
|
||||
|
||||
@@ -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"]
|
||||
@@ -324,13 +324,66 @@ class CaseLifecycleTests(unittest.TestCase):
|
||||
|
||||
outsider = Principal(account_id="account-2")
|
||||
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual(0, list_cases(self.session, outsider)[1])
|
||||
self.assertEqual(
|
||||
0,
|
||||
list_cases(
|
||||
self.session,
|
||||
outsider,
|
||||
purpose="cases.casework",
|
||||
)[1],
|
||||
)
|
||||
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
|
||||
|
||||
reader = Principal(account_id="account-3", group_ids=("reviewers",))
|
||||
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
|
||||
self.assertEqual(1, list_cases(self.session, reader)[1])
|
||||
self.assertIsNone(get_case(self.session, reader, case_id="case-1"))
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.unrelated",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
get_case(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
administrator = Principal(
|
||||
account_id="account-6",
|
||||
scopes=frozenset({"cases:case:admin"}),
|
||||
)
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
administrator,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
list_cases(
|
||||
self.session,
|
||||
reader,
|
||||
purpose="cases.casework",
|
||||
)[1],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
case_history(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
),
|
||||
)
|
||||
with self.assertRaises(PermissionError):
|
||||
update_case(
|
||||
self.session,
|
||||
@@ -396,9 +449,56 @@ class CaseLifecycleTests(unittest.TestCase):
|
||||
self.session.commit()
|
||||
self.assertEqual(3, shared.revision)
|
||||
self.assertIsNotNone(
|
||||
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
|
||||
get_case(
|
||||
self.session,
|
||||
Principal(account_id="account-5"),
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
3,
|
||||
len(
|
||||
case_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
revoked = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=3,
|
||||
changes={"access_grants": []},
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Removed direct collaboration access.",
|
||||
idempotency_key="case-share-revoke",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(4, revoked.revision)
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
Principal(account_id="account-5"),
|
||||
case_id="case-1",
|
||||
revision=2,
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
"cases.casework",
|
||||
case_timeline(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)[0]["purpose"],
|
||||
)
|
||||
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||
|
||||
def test_malformed_access_grant_is_rejected(self) -> None:
|
||||
create_case(
|
||||
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
import test_case_lifecycle as fixture
|
||||
from govoplan_cases.backend import service
|
||||
from govoplan_cases.backend.db.models import CaseAccessGrant, CaseRecordRevision
|
||||
|
||||
|
||||
class CaseListQueryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = fixture.CaseLifecycleTests()
|
||||
self.fixture.setUp()
|
||||
|
||||
def tearDown(self):
|
||||
self.fixture.tearDown()
|
||||
|
||||
def seed(self, count=50, *, restricted=False):
|
||||
for index in range(count):
|
||||
original = fixture.record()
|
||||
reference = replace(original.reference, object_id=f"case-{index:03}")
|
||||
record = replace(
|
||||
original, reference=reference, case_number=f"CASE-{index:03}",
|
||||
context=replace(original.context, case_ref=reference),
|
||||
access_mode="restricted" if restricted else "tenant",
|
||||
)
|
||||
service.create_case(self.fixture.session, self.fixture.principal, record=record, idempotency_key=f"fixture-{index}")
|
||||
self.fixture.session.add(CaseAccessGrant(
|
||||
tenant_id="tenant-1", case_id=reference.object_id, subject_kind="account", subject_id="other-account",
|
||||
permissions=["read"], allowed_purposes=["cases.casework"], source="manual", active=True, source_revision=1,
|
||||
))
|
||||
self.fixture.session.commit()
|
||||
self.fixture.session.expunge_all()
|
||||
|
||||
def test_filtered_empty_page_does_not_materialize_grants_or_case_ids(self):
|
||||
self.seed()
|
||||
loaded = []
|
||||
queries = []
|
||||
|
||||
def row_loaded(target, context):
|
||||
loaded.append(target.id)
|
||||
|
||||
def executed(conn, cursor, statement, parameters, context, executemany):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
queries.append((statement, len(parameters)))
|
||||
|
||||
event.listen(CaseAccessGrant, "load", row_loaded)
|
||||
event.listen(self.fixture.engine, "before_cursor_execute", executed)
|
||||
try:
|
||||
result = service.list_cases(
|
||||
self.fixture.session, self.fixture.principal,
|
||||
query="no-fixture-matches-this", purpose="cases.casework", limit=1,
|
||||
)
|
||||
self.assertEqual(((), 0), result)
|
||||
self.assertEqual([], loaded)
|
||||
self.assertEqual(2, len(queries))
|
||||
self.assertTrue(all(count < 30 for _, count in queries))
|
||||
self.assertIn("LIMIT", queries[-1][0])
|
||||
finally:
|
||||
event.remove(CaseAccessGrant, "load", row_loaded)
|
||||
event.remove(self.fixture.engine, "before_cursor_execute", executed)
|
||||
|
||||
def test_current_grants_exact_purpose_and_permissions_govern_total(self):
|
||||
self.seed(4, restricted=True)
|
||||
reader = fixture.Principal(account_id="other-account")
|
||||
session = self.fixture.session
|
||||
self.assertEqual(((), 0), service.list_cases(session, reader, limit=1))
|
||||
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.case", limit=1))
|
||||
page, total = service.list_cases(session, reader, purpose="cases.casework", offset=1, limit=1)
|
||||
self.assertEqual(4, total)
|
||||
self.assertEqual("case-001", page[0].reference.object_id)
|
||||
grants = session.query(CaseAccessGrant).filter(CaseAccessGrant.subject_id == "other-account").order_by(CaseAccessGrant.case_id).all()
|
||||
grants[0].active = False
|
||||
grants[1].permissions = ["reader"]
|
||||
grants[2].allowed_purposes = ["cases.casework.extra"]
|
||||
grants[3].permissions = ["update"]
|
||||
session.commit()
|
||||
self.assertEqual(1, service.list_cases(session, reader, purpose="cases.casework", limit=1)[1])
|
||||
grants[3].allowed_purposes = {"not_an_array": "cases.casework"}
|
||||
session.commit()
|
||||
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.casework", limit=1))
|
||||
self.assertEqual(((), 0), service.list_cases(session, fixture.Principal(tenant_id="tenant-2", account_id="other-account"), purpose="cases.casework", limit=1))
|
||||
|
||||
def test_access_predicate_compiles_for_postgresql_without_case_id_lists(self):
|
||||
compiled = select(CaseRecordRevision.case_id).where(
|
||||
service._case_access_predicate(self.fixture.principal, permission="read", purpose="cases.casework"),
|
||||
).compile(dialect=postgresql.dialect())
|
||||
self.assertIn("EXISTS", str(compiled))
|
||||
self.assertIn("json_array_elements", str(compiled))
|
||||
self.assertNotIn("cases.casework", str(compiled))
|
||||
@@ -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,479 @@
|
||||
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"])
|
||||
self.assertEqual(
|
||||
["cases.casework"],
|
||||
account_records[0].data["allowed_purposes"],
|
||||
)
|
||||
|
||||
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_access_grant",
|
||||
"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()
|
||||
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in get_manifest().documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -26,6 +34,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"cases.detail.timeline",
|
||||
"cases.detail.history",
|
||||
"cases.detail.access",
|
||||
"cases.detail.decision",
|
||||
},
|
||||
set(surfaces),
|
||||
)
|
||||
@@ -37,6 +46,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,17 +54,37 @@ 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.assertEqual("reference", reference.metadata["kind"])
|
||||
self.assertIn(
|
||||
"link_owner_reference",
|
||||
owner_links.metadata["consequence_classes"],
|
||||
)
|
||||
|
||||
def test_information_governance_claims_are_bounded_and_documented(self) -> None:
|
||||
manifest = get_manifest()
|
||||
governance = manifest.information_governance
|
||||
self.assertEqual("enforced", governance.temporal_browsing.adoption)
|
||||
self.assertEqual("enforced", governance.purpose_aware_access.adoption)
|
||||
self.assertEqual("enforced", governance.institutional_context.adoption)
|
||||
self.assertEqual("contract_only", governance.retention.adoption)
|
||||
self.assertIn("legal holds", governance.retention.limitation or "")
|
||||
self.assertTrue(governance.current_authorization_for_historical_reads)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
purpose = topics["cases.governance.purpose-bound-access"]
|
||||
self.assertIn("cases.field.access-purpose", purpose.metadata["help_contexts"])
|
||||
self.assertIn("cases.field.allowed-purposes", purpose.metadata["help_contexts"])
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
list_page = (
|
||||
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
detail_page = (
|
||||
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
@@ -62,17 +92,21 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("DocumentationHelpLink", list_page)
|
||||
self.assertIn("titleHelp={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}", list_page)
|
||||
self.assertNotIn("helpAction=", list_page)
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"DocumentationHelpLink",
|
||||
'TextWithHelp as="div" help={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}',
|
||||
"FormField",
|
||||
"useUnsavedDraftGuard",
|
||||
"QUICK_ACCESS_RESULT_EVENT",
|
||||
"linkCaseEvidence",
|
||||
"unlinkCaseEvidence",
|
||||
):
|
||||
self.assertIn(component, detail_page)
|
||||
for component in (
|
||||
"ConfirmDialog",
|
||||
"DocumentationHelpLink",
|
||||
"titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}",
|
||||
"ReferenceSelect",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
|
||||
@@ -40,9 +40,23 @@ class CasesMigrationTests(unittest.TestCase):
|
||||
for item in inspector.get_columns("case_record_revisions")
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"allowed_purposes",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspector.get_columns("case_access_grants")
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"purpose",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspector.get_columns("case_timeline_entries")
|
||||
},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"f6d3a8b1c4e7",
|
||||
"a7c4e2f9b1d6",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
) as can_access:
|
||||
result = CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
locator=self.locator(),
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
can_access.assert_called_once_with(
|
||||
self.session,
|
||||
unittest.mock.ANY,
|
||||
case_id="case-1",
|
||||
permission="read",
|
||||
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"])
|
||||
self.assertEqual(
|
||||
"/cases/case-1?purpose=preserve%20decision%20basis",
|
||||
result.launch_url,
|
||||
)
|
||||
|
||||
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,188 @@
|
||||
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 CaseGrant, CaseRecord
|
||||
from govoplan_cases.backend.search_source import (
|
||||
SEARCH_ACCESS_PURPOSE,
|
||||
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",
|
||||
access_mode="restricted",
|
||||
access_grants=(
|
||||
CaseGrant(
|
||||
"account",
|
||||
"account-1",
|
||||
("read",),
|
||||
(SEARCH_ACCESS_PURPOSE,),
|
||||
),
|
||||
),
|
||||
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"])
|
||||
self.assertIn("purpose=cases.search", page.documents[0].url)
|
||||
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.15",
|
||||
"version": "0.1.25",
|
||||
"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.15",
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+138
-6
@@ -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 = {
|
||||
@@ -48,11 +59,14 @@ export type CaseRecord = {
|
||||
};
|
||||
|
||||
export type CaseGrant = {
|
||||
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
|
||||
subject_kind: "account" | "identity" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
|
||||
subject_id: string;
|
||||
permissions: Array<"read" | "update" | "share" | "admin">;
|
||||
allowed_purposes: string[];
|
||||
};
|
||||
|
||||
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
|
||||
|
||||
export type CaseStatusDefinition = {
|
||||
status_key: string;
|
||||
label: string;
|
||||
@@ -90,18 +104,47 @@ export type CaseTimelineEntry = {
|
||||
event_type: string;
|
||||
case_revision: number;
|
||||
summary: string;
|
||||
purpose: string;
|
||||
actor_id?: string | null;
|
||||
occurred_at: string;
|
||||
audit_event_id?: string | null;
|
||||
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: {
|
||||
query?: string;
|
||||
statuses?: string[];
|
||||
caseTypes?: string[];
|
||||
purpose?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
},
|
||||
@@ -111,6 +154,7 @@ export function listCases(
|
||||
query: options.query,
|
||||
status_key: options.statuses,
|
||||
case_type_key: options.caseTypes,
|
||||
purpose: options.purpose,
|
||||
offset: options.offset,
|
||||
limit: options.limit
|
||||
}), { signal });
|
||||
@@ -119,9 +163,10 @@ export function listCases(
|
||||
export function getCase(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
|
||||
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function listCaseCatalog(
|
||||
@@ -134,17 +179,57 @@ export function listCaseCatalog(
|
||||
export function caseHistory(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ revisions: CaseRecord[] }> {
|
||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
|
||||
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function caseTimeline(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ entries: CaseTimelineEntry[] }> {
|
||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
||||
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function listCaseDecisions(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ decisions: FormalDecision[] }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
|
||||
{ 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;
|
||||
purpose: string;
|
||||
}
|
||||
): Promise<CaseDecisionResult> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCase(
|
||||
@@ -155,6 +240,7 @@ export function updateCase(
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: string;
|
||||
title?: string;
|
||||
status_key?: string;
|
||||
access_mode?: "tenant" | "restricted";
|
||||
@@ -167,14 +253,60 @@ export function updateCase(
|
||||
});
|
||||
}
|
||||
|
||||
export function linkCaseEvidence(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: 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;
|
||||
purpose: 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,
|
||||
targetType: "user" | "group"
|
||||
targetType: "user" | "group",
|
||||
purpose: string
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
|
||||
{ target_type: targetType }
|
||||
{ target_type: targetType, purpose }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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,
|
||||
purpose,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
purpose: string;
|
||||
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,
|
||||
purpose
|
||||
});
|
||||
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 })}
|
||||
titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}
|
||||
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">
|
||||
{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,33 +1,46 @@
|
||||
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 { useParams, useSearchParams } from "react-router";
|
||||
import { ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
TextWithHelp,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
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 {
|
||||
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
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,
|
||||
@@ -37,11 +50,14 @@ import {
|
||||
|
||||
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
||||
const { caseId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||
const navigate = useGuardedNavigate();
|
||||
const [record, setRecord] = useState<CaseRecord | null>(null);
|
||||
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,31 +65,65 @@ 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)}?purpose=${encodeURIComponent(accessPurpose)}`
|
||||
} : null, [accessPurpose, record]);
|
||||
useRegisterActiveObject(activeCase);
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return Promise.all([
|
||||
getCase(settings, caseId, signal),
|
||||
getCase(settings, caseId, accessPurpose, signal),
|
||||
listCaseCatalog(settings, signal),
|
||||
caseHistory(settings, caseId, signal),
|
||||
caseTimeline(settings, caseId, signal)
|
||||
caseHistory(settings, caseId, accessPurpose, signal),
|
||||
caseTimeline(settings, caseId, accessPurpose, signal),
|
||||
canReadDecisions
|
||||
? listCaseDecisions(settings, caseId, accessPurpose, 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]);
|
||||
}, [accessPurpose, canReadDecisions, caseId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -96,6 +146,70 @@ 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(),
|
||||
purpose: accessPurpose,
|
||||
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
|
||||
);
|
||||
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
|
||||
|
||||
function discardDraft() {
|
||||
if (!record) return;
|
||||
setTitle(record.title);
|
||||
@@ -113,6 +227,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
purpose: accessPurpose,
|
||||
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
||||
...(status !== record.status_key ? { status_key: status } : {})
|
||||
});
|
||||
@@ -137,6 +252,37 @@ 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(),
|
||||
purpose: accessPurpose
|
||||
}
|
||||
);
|
||||
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 +302,41 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
return (
|
||||
<main className="cases-page">
|
||||
<div className="case-detail-shell">
|
||||
<div className="case-detail-toolbar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
|
||||
<ActionToolbar className="case-detail-toolbar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
|
||||
<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}
|
||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||
</div>
|
||||
{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)}
|
||||
helpContextId="cases.detail.access"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ActionToolbar>
|
||||
<PageScrollViewport className="case-detail-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
@@ -184,7 +350,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<div className="case-detail-title-row">
|
||||
<div>
|
||||
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
|
||||
<h1>{record.title}</h1>
|
||||
<TextWithHelp as="div" help={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}><h1>{record.title}</h1></TextWithHelp>
|
||||
</div>
|
||||
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
|
||||
</div>
|
||||
@@ -245,12 +411,37 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
|
||||
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
|
||||
<Fact label="Revision" value={String(record.revision)} />
|
||||
<Fact label="Access purpose" value={accessPurpose} />
|
||||
<Fact label="Access mode" value={humanize(record.access_mode)} />
|
||||
<Fact label="Last change" value={record.change_reason} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -262,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<li key={entry.event_id}>
|
||||
<strong>{humanize(entry.event_type)}</strong>
|
||||
<span>{entry.summary}</span>
|
||||
<span>{entry.purpose}</span>
|
||||
<time>{formatDateTime(entry.occurred_at)}</time>
|
||||
</li>
|
||||
)}
|
||||
@@ -288,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<CaseShareDialog
|
||||
settings={settings}
|
||||
record={record}
|
||||
purpose={accessPurpose}
|
||||
open={shareOpen}
|
||||
onClose={() => setShareOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
@@ -298,6 +491,25 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{record ? (
|
||||
<CaseDecisionDialog
|
||||
settings={settings}
|
||||
record={record}
|
||||
purpose={accessPurpose}
|
||||
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 +535,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)) : "-";
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
|
||||
export default function CaseShareDialog({
|
||||
settings,
|
||||
record,
|
||||
purpose,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
purpose: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (record: CaseRecord) => void;
|
||||
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
|
||||
const [targetType, setTargetType] = useState<TargetType>("user");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [permission, setPermission] = useState<Permission>("read");
|
||||
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const targetProvider = useMemo(
|
||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
|
||||
[record.reference.object_id, settings, targetType]
|
||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
|
||||
[purpose, record.reference.object_id, settings, targetType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setAllowedPurposes(purpose);
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
setConfirmOpen(false);
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
}, [open, record]);
|
||||
}, [open, purpose, record]);
|
||||
|
||||
const changed = restricted !== (record.access_mode === "restricted")
|
||||
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
||||
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setAllowedPurposes(purpose);
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addGrant() {
|
||||
const subjectId = targetId.trim();
|
||||
if (!subjectId) return;
|
||||
const purposes = splitPurposes(allowedPurposes);
|
||||
if (!subjectId || purposes.length === 0) return;
|
||||
const subjectKind = targetType === "user" ? "account" : "group";
|
||||
setGrants((current) => [
|
||||
...current.filter(
|
||||
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
|
||||
{
|
||||
subject_kind: subjectKind,
|
||||
subject_id: subjectId,
|
||||
permissions: [permission]
|
||||
permissions: [permission],
|
||||
allowed_purposes: purposes
|
||||
}
|
||||
]);
|
||||
setTargetId("");
|
||||
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
purpose,
|
||||
access_mode: restricted ? "restricted" : "tenant",
|
||||
access_grants: grants
|
||||
});
|
||||
@@ -165,10 +173,12 @@ export default function CaseShareDialog({
|
||||
<Dialog
|
||||
open={open}
|
||||
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
|
||||
titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}
|
||||
onClose={close}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="case-share-dialog"
|
||||
helpContextId="cases.detail.access"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={close}>Cancel</Button>
|
||||
@@ -176,6 +186,7 @@ export default function CaseShareDialog({
|
||||
variant="primary"
|
||||
disabledReason={saveDisabledReason}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
helpContextId="cases.detail.access"
|
||||
>
|
||||
{busy ? "Saving" : "Save access"}
|
||||
</Button>
|
||||
@@ -183,7 +194,6 @@ export default function CaseShareDialog({
|
||||
}
|
||||
>
|
||||
<div className="case-share-content">
|
||||
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
||||
) : null}
|
||||
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
|
||||
onChange={setRestricted}
|
||||
/>
|
||||
<p className="case-share-explanation">
|
||||
Tenant cases follow the Cases read permission. Restricted cases are visible only to
|
||||
their creator, case administrators, assigned functions or units, and the explicit
|
||||
grants below.
|
||||
Tenant cases follow the Cases read permission. Restricted cases require a current
|
||||
grant whose permission and exact allowed purpose both match the attempted action.
|
||||
The creating account receives a purpose-bound custodian grant.
|
||||
</p>
|
||||
|
||||
<div className="case-share-add-row">
|
||||
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
|
||||
<option value="admin">Administer</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={allowedPurposes}
|
||||
disabled={busy}
|
||||
onChange={(event) => setAllowedPurposes(event.target.value)}
|
||||
placeholder="cases.casework, cases.search"
|
||||
/>
|
||||
</FormField>
|
||||
<IconButton
|
||||
label="Add access grant"
|
||||
icon={<Plus size={16} />}
|
||||
variant="primary"
|
||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
|
||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
|
||||
onClick={addGrant}
|
||||
/>
|
||||
</div>
|
||||
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
|
||||
<option value="share">Share</option>
|
||||
<option value="admin">Administer</option>
|
||||
</select>
|
||||
<input
|
||||
aria-label={`Allowed purposes for ${grant.subject_id}`}
|
||||
value={grant.allowed_purposes.join(", ")}
|
||||
disabled={busy}
|
||||
onChange={(event) => setGrants((current) => current.map((item) =>
|
||||
item === grant
|
||||
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
|
||||
: item
|
||||
))}
|
||||
/>
|
||||
<IconButton
|
||||
label={`Remove access for ${grant.subject_id}`}
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
helpContextId="cases.detail.access"
|
||||
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
|
||||
/>
|
||||
</div>
|
||||
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function splitPurposes(value: string): string[] {
|
||||
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FilterBar,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
i18nMessage,
|
||||
useGuardedNavigate,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
listCaseCatalog,
|
||||
listCases,
|
||||
type CaseCatalog,
|
||||
@@ -22,14 +30,19 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
|
||||
|
||||
export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [purpose, setPurpose] = useState(initialPurpose);
|
||||
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
|
||||
const [status, setStatus] = useState("");
|
||||
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -39,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
listCases(settings, {
|
||||
query: submittedQuery,
|
||||
statuses: status ? [status] : undefined,
|
||||
purpose: submittedPurpose,
|
||||
limit: 200
|
||||
}, controller.signal),
|
||||
listCaseCatalog(settings, controller.signal)
|
||||
@@ -55,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [settings, status, submittedQuery]);
|
||||
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
|
||||
|
||||
const statusLabels = useMemo(
|
||||
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
||||
@@ -69,13 +83,22 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedQuery(query.trim());
|
||||
setSubmittedPurpose(purpose.trim());
|
||||
}
|
||||
|
||||
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
|
||||
title="Cases"
|
||||
titleHelp={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
|
||||
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}
|
||||
@@ -83,6 +106,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
aria-label="Search cases"
|
||||
placeholder="Search cases"
|
||||
/>
|
||||
<input
|
||||
value={purpose}
|
||||
onChange={(event) => setPurpose(event.target.value)}
|
||||
aria-label="Case access purpose"
|
||||
placeholder="Case access purpose"
|
||||
data-help-context-id="cases.field.access-purpose"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -90,8 +121,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 +130,10 @@ 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>
|
||||
</>}
|
||||
/>
|
||||
<PageScrollViewport className="cases-list-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||
@@ -111,36 +142,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>
|
||||
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
|
||||
<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,33 @@ 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.",
|
||||
"Case access purpose": "Case access purpose",
|
||||
"Access purpose": "Access purpose",
|
||||
"Access mode": "Access mode",
|
||||
"Allowed purposes": "Allowed purposes",
|
||||
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
|
||||
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
|
||||
"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 +121,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 +189,33 @@ 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.",
|
||||
"Case access purpose": "Zugriffszweck für Vorgänge",
|
||||
"Access purpose": "Zugriffszweck",
|
||||
"Access mode": "Zugriffsmodus",
|
||||
"Allowed purposes": "Erlaubte Zwecke",
|
||||
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
|
||||
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
|
||||
"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.21",
|
||||
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