Integrate formal decisions with cases
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
ActorRepresentationReference,
|
||||
DecisionRegistry,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
MandateResolutionRequest,
|
||||
MandateResolver,
|
||||
TemporalRevision,
|
||||
resolve_mandate_candidates,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.service import (
|
||||
can_access_case,
|
||||
get_case,
|
||||
update_case,
|
||||
)
|
||||
|
||||
|
||||
DECISION_READ_SCOPE = "decisions:decision:read"
|
||||
DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive"
|
||||
DECISION_WRITE_SCOPE = "decisions:decision:write"
|
||||
_DECISION_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
"https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1",
|
||||
)
|
||||
|
||||
|
||||
class CaseDecisionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CaseDecisionUnavailable(CaseDecisionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaseDecisionCommand:
|
||||
expected_case_revision: int
|
||||
effective_at: datetime
|
||||
decision_type: str
|
||||
operative_result: str
|
||||
reasoning: str
|
||||
change_reason: str
|
||||
idempotency_key: str
|
||||
conditions: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaseDecisionResult:
|
||||
case: CaseRecord
|
||||
decision: FormalDecision
|
||||
replayed: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"case": self.case.to_dict(),
|
||||
"decision": self.decision.to_dict(include_protected=True),
|
||||
"replayed": self.replayed,
|
||||
}
|
||||
|
||||
|
||||
class CaseDecisionPath:
|
||||
"""Record a formal outcome and its exact Case reference atomically."""
|
||||
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def record(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
command: CaseDecisionCommand,
|
||||
) -> CaseDecisionResult:
|
||||
_validate_command(command)
|
||||
source_case = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=command.expected_case_revision,
|
||||
)
|
||||
current = get_case(session, principal, case_id=case_id)
|
||||
if source_case is None or current is None:
|
||||
raise LookupError("Case not found.")
|
||||
if not can_access_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
permission="update",
|
||||
):
|
||||
raise PermissionError("Case update access is denied.")
|
||||
|
||||
decision_id = _decision_id(source_case, command.idempotency_key)
|
||||
existing_ref = next(
|
||||
(
|
||||
item
|
||||
for item in current.decision_refs
|
||||
if item.owner_module == "decisions" and item.object_id == decision_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_ref is None and current.revision != command.expected_case_revision:
|
||||
raise CaseDecisionError(
|
||||
"Case revision conflict: reload the Case before recording a Decision."
|
||||
)
|
||||
|
||||
decision_registry = _decision_registry(self._registry)
|
||||
mandate = _resolve_mandate(
|
||||
self._registry,
|
||||
session,
|
||||
principal,
|
||||
case=source_case,
|
||||
command=command,
|
||||
)
|
||||
actor = _resolve_actor(
|
||||
self._registry,
|
||||
principal,
|
||||
case=source_case,
|
||||
mandate=mandate,
|
||||
effective_at=command.effective_at,
|
||||
)
|
||||
decision = _decision(
|
||||
source_case,
|
||||
command=command,
|
||||
decision_id=decision_id,
|
||||
mandate=mandate,
|
||||
actor=actor,
|
||||
)
|
||||
persisted = decision_registry.record_decision(
|
||||
session,
|
||||
principal,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
if existing_ref is not None:
|
||||
return CaseDecisionResult(
|
||||
case=current,
|
||||
decision=persisted,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
linked = update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=current.revision,
|
||||
changes={"decision_refs": (*current.decision_refs, persisted.reference)},
|
||||
recorded_at=command.effective_at,
|
||||
change_reason=command.change_reason,
|
||||
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
||||
)
|
||||
return CaseDecisionResult(case=linked, decision=persisted)
|
||||
|
||||
def linked(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
) -> tuple[FormalDecision, ...]:
|
||||
case = get_case(session, principal, case_id=case_id)
|
||||
if case is None:
|
||||
raise LookupError("Case not found.")
|
||||
registry = _decision_registry(self._registry)
|
||||
resolved: list[FormalDecision] = []
|
||||
for reference in case.decision_refs:
|
||||
if reference.owner_module != "decisions":
|
||||
continue
|
||||
item = registry.get_decision(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if item is not None:
|
||||
resolved.append(item)
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _decision(
|
||||
case: CaseRecord,
|
||||
*,
|
||||
command: CaseDecisionCommand,
|
||||
decision_id: str,
|
||||
mandate: MandateDefinition,
|
||||
actor: ActorRepresentationReference,
|
||||
) -> FormalDecision:
|
||||
evidence = tuple(
|
||||
dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence))
|
||||
)
|
||||
legal_bases = tuple(
|
||||
dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases))
|
||||
)
|
||||
if not evidence:
|
||||
raise CaseDecisionError(
|
||||
"A formal Decision requires evidence linked to the exact Case revision."
|
||||
)
|
||||
if not legal_bases:
|
||||
raise CaseDecisionError(
|
||||
"A formal Decision requires an effective legal-basis version."
|
||||
)
|
||||
decision_ref = InstitutionalReference(
|
||||
kind="decision",
|
||||
owner_module="decisions",
|
||||
object_id=decision_id,
|
||||
tenant_id=case.reference.tenant_id,
|
||||
version="1",
|
||||
valid_at=command.effective_at,
|
||||
)
|
||||
temporal = TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=command.effective_at,
|
||||
recorded_at=command.effective_at,
|
||||
change_reason=command.change_reason,
|
||||
)
|
||||
authority_context: GovernedContextEnvelope = replace(
|
||||
case.context,
|
||||
temporal=temporal,
|
||||
actor=actor,
|
||||
mandate_ref=mandate.reference,
|
||||
case_ref=case.reference,
|
||||
party_refs=case.party_refs or case.context.party_refs,
|
||||
decision_ref=decision_ref,
|
||||
record_refs=case.record_refs,
|
||||
legal_bases=legal_bases,
|
||||
evidence=evidence,
|
||||
)
|
||||
return FormalDecision(
|
||||
reference=decision_ref,
|
||||
temporal=temporal,
|
||||
decision_type=command.decision_type.strip(),
|
||||
subject_refs=case.party_refs or (case.reference,),
|
||||
state="decided",
|
||||
authority_context=authority_context,
|
||||
fact_evidence=evidence,
|
||||
legal_bases=legal_bases,
|
||||
operative_result=command.operative_result.strip(),
|
||||
reasoning=command.reasoning.strip(),
|
||||
conditions=tuple(item.strip() for item in command.conditions if item.strip()),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mandate(
|
||||
registry: object | None,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
command: CaseDecisionCommand,
|
||||
) -> MandateDefinition:
|
||||
resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER)
|
||||
if not isinstance(resolver, MandateResolver):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require an enabled Mandates resolver."
|
||||
)
|
||||
request = MandateResolutionRequest(
|
||||
tenant_id=case.reference.tenant_id,
|
||||
effective_at=command.effective_at,
|
||||
task_type="cases.formal_decision",
|
||||
authority_type=command.decision_type.strip(),
|
||||
organization_unit_ref=case.context.organization_unit_ref,
|
||||
function_ref=case.context.function_ref,
|
||||
jurisdiction_refs=case.context.jurisdiction_refs,
|
||||
)
|
||||
resolution: MandateResolution = resolver.resolve_mandate(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
if not resolution.competent:
|
||||
raise CaseDecisionError(
|
||||
resolution.explanation or "The acting function is not competent to decide."
|
||||
)
|
||||
if resolution.conflict_refs:
|
||||
raise CaseDecisionError(
|
||||
"Mandate resolution has unresolved conflicts: "
|
||||
+ ", ".join(resolution.conflict_refs)
|
||||
)
|
||||
verified = resolve_mandate_candidates(request, resolution.mandates)
|
||||
if not verified.competent or len(verified.mandates) != 1:
|
||||
raise CaseDecisionError(
|
||||
verified.explanation
|
||||
or "A formal Decision requires exactly one effective active Mandate."
|
||||
)
|
||||
return verified.mandates[0]
|
||||
|
||||
|
||||
def _resolve_actor(
|
||||
registry: object | None,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
mandate: MandateDefinition,
|
||||
effective_at: datetime,
|
||||
) -> ActorRepresentationReference:
|
||||
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||
lookup = getattr(directory, "get_function_assignment", None)
|
||||
if not callable(lookup):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require the Access semantic directory to verify the acting assignment."
|
||||
)
|
||||
acting_id = _text(getattr(principal, "acting_assignment_id", None))
|
||||
granted_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(item).strip()
|
||||
for item in (getattr(principal, "function_assignment_ids", ()) or ())
|
||||
if str(item).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if acting_id and acting_id not in granted_ids:
|
||||
raise PermissionError("The selected acting assignment is not active for this account.")
|
||||
candidate_ids = (acting_id,) if acting_id else granted_ids
|
||||
matching = []
|
||||
for assignment_id in candidate_ids:
|
||||
assignment = lookup(assignment_id)
|
||||
if assignment is None or not _assignment_matches(
|
||||
assignment,
|
||||
principal,
|
||||
case=case,
|
||||
effective_at=effective_at,
|
||||
):
|
||||
continue
|
||||
matching.append(assignment)
|
||||
if not matching:
|
||||
raise PermissionError(
|
||||
"No active acting assignment matches the Case organization and function."
|
||||
)
|
||||
if len(matching) > 1:
|
||||
raise CaseDecisionError(
|
||||
"Select one acting assignment in the title bar before recording the Decision."
|
||||
)
|
||||
assignment = matching[0]
|
||||
return ActorRepresentationReference(
|
||||
tenant_id=case.reference.tenant_id,
|
||||
account_id=_required_principal_text(principal, "account_id"),
|
||||
identity_id=_text(getattr(principal, "identity_id", None))
|
||||
or _text(getattr(assignment, "identity_id", None)),
|
||||
represented_account_id=_text(
|
||||
getattr(principal, "acting_for_account_id", None)
|
||||
),
|
||||
represented_function_ref=case.context.function_ref,
|
||||
function_assignment_ref=InstitutionalReference(
|
||||
kind="function_assignment",
|
||||
owner_module="access",
|
||||
object_id=str(getattr(assignment, "id")),
|
||||
tenant_id=case.reference.tenant_id,
|
||||
valid_at=effective_at,
|
||||
),
|
||||
delegation_ref=_text(
|
||||
getattr(assignment, "delegated_from_assignment_id", None)
|
||||
),
|
||||
mandate_ref=mandate.reference,
|
||||
)
|
||||
|
||||
|
||||
def _assignment_matches(
|
||||
assignment: object,
|
||||
principal: object,
|
||||
*,
|
||||
case: CaseRecord,
|
||||
effective_at: datetime,
|
||||
) -> bool:
|
||||
organization = case.context.organization_unit_ref
|
||||
function = case.context.function_ref
|
||||
if organization is None or function is None:
|
||||
raise CaseDecisionError(
|
||||
"The Case lacks the responsible organization and function required for a formal Decision."
|
||||
)
|
||||
if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id:
|
||||
return False
|
||||
if str(getattr(assignment, "account_id", "")) != _required_principal_text(
|
||||
principal, "account_id"
|
||||
):
|
||||
return False
|
||||
if str(getattr(assignment, "function_id", "")) != function.object_id:
|
||||
return False
|
||||
if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id:
|
||||
return False
|
||||
if str(getattr(assignment, "status", "active")) != "active":
|
||||
return False
|
||||
valid_from = getattr(assignment, "valid_from", None)
|
||||
valid_until = getattr(assignment, "valid_until", None)
|
||||
return not (
|
||||
(valid_from is not None and effective_at < valid_from)
|
||||
or (valid_until is not None and effective_at >= valid_until)
|
||||
)
|
||||
|
||||
|
||||
def _decision_registry(registry: object | None) -> DecisionRegistry:
|
||||
provider = _capability(registry, CAPABILITY_DECISION_REGISTRY)
|
||||
if not isinstance(provider, DecisionRegistry):
|
||||
raise CaseDecisionUnavailable(
|
||||
"Formal Decisions require an enabled Decision registry."
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _decision_id(case: CaseRecord, idempotency_key: str) -> str:
|
||||
return str(
|
||||
uuid.uuid5(
|
||||
_DECISION_NAMESPACE,
|
||||
":".join(
|
||||
(
|
||||
case.reference.tenant_id,
|
||||
case.reference.object_id,
|
||||
str(case.revision),
|
||||
idempotency_key.strip(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_command(command: CaseDecisionCommand) -> None:
|
||||
for value, label in (
|
||||
(command.decision_type, "Decision type"),
|
||||
(command.operative_result, "Operative result"),
|
||||
(command.reasoning, "Decision reasoning"),
|
||||
(command.change_reason, "Decision change reason"),
|
||||
(command.idempotency_key, "Decision idempotency key"),
|
||||
):
|
||||
if not value.strip():
|
||||
raise CaseDecisionError(f"{label} is required.")
|
||||
if command.expected_case_revision < 1:
|
||||
raise CaseDecisionError("Expected Case revision must be positive.")
|
||||
if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None:
|
||||
raise CaseDecisionError("Decision effective_at must include a timezone.")
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _text(value: object | None) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _required_principal_text(principal: object, name: str) -> str:
|
||||
value = _text(getattr(principal, name, None))
|
||||
if value is None:
|
||||
raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CaseDecisionCommand",
|
||||
"CaseDecisionError",
|
||||
"CaseDecisionPath",
|
||||
"CaseDecisionResult",
|
||||
"CaseDecisionUnavailable",
|
||||
"DECISION_READ_SCOPE",
|
||||
"DECISION_SENSITIVE_READ_SCOPE",
|
||||
"DECISION_WRITE_SCOPE",
|
||||
]
|
||||
@@ -6,7 +6,12 @@ from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
@@ -24,6 +29,7 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
@@ -51,6 +57,7 @@ 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_core.db.base import Base
|
||||
|
||||
|
||||
@@ -138,7 +145,12 @@ manifest = ModuleManifest(
|
||||
"workflow_engine",
|
||||
"records",
|
||||
),
|
||||
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
),
|
||||
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."),
|
||||
@@ -282,6 +294,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=(
|
||||
@@ -295,6 +315,8 @@ manifest = ModuleManifest(
|
||||
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,
|
||||
@@ -358,8 +380,57 @@ 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.record-formal-decision",
|
||||
title="Record a formal Decision from a Case",
|
||||
summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.",
|
||||
body=(
|
||||
"The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. "
|
||||
"Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through "
|
||||
"Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then "
|
||||
"records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision "
|
||||
"in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "decision_officer", "operator", "auditor"),
|
||||
related_modules=("access", "mandates", "decisions", "records"),
|
||||
order=14,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases", "access", "mandates", "decisions"),
|
||||
required_scopes=("cases:case:update", "decisions:decision:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
||||
"prerequisites": [
|
||||
"The Case has an exact responsible organization, function, evidence, and legal-basis version.",
|
||||
"An active acting assignment and exactly one effective Mandate authorize the Decision type.",
|
||||
],
|
||||
"steps": [
|
||||
"Open the Case and select the appropriate acting assignment in the title bar.",
|
||||
"Record the operative result, reasoning, optional conditions, effective time, and change reason.",
|
||||
"Confirm the exact Decision and Case revisions in the Case timeline.",
|
||||
"When Records is enabled, file the exact Decision or Case revision into the destination eAkte.",
|
||||
],
|
||||
"outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.file-exact-revision",
|
||||
title="File an exact case revision into an eAkte",
|
||||
@@ -412,7 +483,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"),
|
||||
@@ -428,6 +502,7 @@ manifest = ModuleManifest(
|
||||
"help_contexts": [
|
||||
"cases.list",
|
||||
"cases.detail",
|
||||
"cases.search.result",
|
||||
"cases.state.read-only",
|
||||
"cases.state.restricted",
|
||||
],
|
||||
|
||||
@@ -32,6 +32,7 @@ from govoplan_cases.backend.manifest import (
|
||||
UPDATE_SCOPE,
|
||||
)
|
||||
from govoplan_cases.backend.schemas import (
|
||||
CaseDecisionRequest,
|
||||
CaseHistoryResponse,
|
||||
CaseListResponse,
|
||||
CaseStatusWriteRequest,
|
||||
@@ -40,6 +41,15 @@ from govoplan_cases.backend.schemas import (
|
||||
CaseUpdateRequest,
|
||||
CaseWriteRequest,
|
||||
)
|
||||
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 +76,9 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
lowered = message.casefold()
|
||||
if isinstance(exc, LookupError):
|
||||
if isinstance(exc, CaseDecisionUnavailable):
|
||||
code = status.HTTP_424_FAILED_DEPENDENCY
|
||||
elif isinstance(exc, LookupError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
@@ -206,6 +218,78 @@ def api_create_case(
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
||||
def api_case_decisions(
|
||||
case_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
_require(principal, READ_SCOPE)
|
||||
_require(principal, DECISION_READ_SCOPE)
|
||||
try:
|
||||
items = CaseDecisionPath(get_registry()).linked(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
)
|
||||
except (
|
||||
CaseDecisionError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
raise _error(exc) from exc
|
||||
disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE)
|
||||
return {
|
||||
"decisions": [
|
||||
item.to_dict(include_protected=disclose)
|
||||
for item in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{case_id}/decisions",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_record_case_decision(
|
||||
case_id: str,
|
||||
payload: CaseDecisionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
_require(principal, DECISION_WRITE_SCOPE)
|
||||
try:
|
||||
result = CaseDecisionPath(get_registry()).record(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
command=CaseDecisionCommand(
|
||||
expected_case_revision=payload.expected_case_revision,
|
||||
effective_at=payload.effective_at,
|
||||
decision_type=payload.decision_type,
|
||||
operative_result=payload.operative_result,
|
||||
reasoning=payload.reasoning,
|
||||
conditions=tuple(payload.conditions),
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseDecisionError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/{case_id}", response_model=dict[str, Any])
|
||||
def api_get_case(
|
||||
case_id: str,
|
||||
|
||||
@@ -82,6 +82,19 @@ class CaseUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CaseDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_case_revision: int = Field(ge=1)
|
||||
effective_at: datetime
|
||||
decision_type: str = Field(min_length=1, max_length=120)
|
||||
operative_result: str = Field(min_length=1, max_length=20_000)
|
||||
reasoning: str = Field(min_length=1, max_length=50_000)
|
||||
conditions: list[str] = Field(default_factory=list, max_length=100)
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
cases: list[dict[str, Any]]
|
||||
total: int
|
||||
@@ -99,6 +112,7 @@ class CaseTimelineResponse(BaseModel):
|
||||
|
||||
__all__ = [
|
||||
"CaseGrantRequest",
|
||||
"CaseDecisionRequest",
|
||||
"CaseHistoryResponse",
|
||||
"CaseListResponse",
|
||||
"CaseStatusWriteRequest",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
)
|
||||
from govoplan_cases.backend.service import can_access_case
|
||||
|
||||
|
||||
PROVIDER_ID = "cases.cases"
|
||||
RESOURCE_TYPE = "case"
|
||||
READ_SCOPE = "cases:case:read"
|
||||
ADMIN_SCOPE = "cases:case:admin"
|
||||
|
||||
|
||||
class CasesSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="cases",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Cases",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = (
|
||||
select(CaseRecordRevision, CaseIdentity)
|
||||
.join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id)
|
||||
.where(
|
||||
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(CaseRecordRevision.case_id > request.cursor)
|
||||
rows = list(
|
||||
db.execute(
|
||||
statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1)
|
||||
).all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
grants = _grants_by_case(
|
||||
db,
|
||||
request.tenant_id,
|
||||
[row.case_id for row, _identity in selected],
|
||||
)
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(CaseRecordRevision.recorded_at)).where(
|
||||
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(
|
||||
row,
|
||||
identity=identity,
|
||||
grants=grants.get(row.case_id, ()),
|
||||
)
|
||||
for row, identity in selected
|
||||
),
|
||||
next_cursor=selected[-1][0].case_id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "cases"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = can_access_case(
|
||||
db,
|
||||
principal,
|
||||
case_id=reference.resource_id,
|
||||
permission="read",
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "cases"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type != RESOURCE_TYPE
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
row = db.scalar(
|
||||
select(CaseRecordRevision).where(
|
||||
CaseRecordRevision.tenant_id == event.tenant.id,
|
||||
CaseRecordRevision.case_id == event.resource.id,
|
||||
CaseRecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
identity = db.scalar(
|
||||
select(CaseIdentity).where(
|
||||
CaseIdentity.tenant_id == event.tenant.id,
|
||||
CaseIdentity.case_id == event.resource.id,
|
||||
)
|
||||
)
|
||||
deleted = row is None or identity is None
|
||||
cursor = event.event_id
|
||||
document = None
|
||||
if not deleted:
|
||||
document = _document(
|
||||
row,
|
||||
identity=identity,
|
||||
grants=_grants_by_case(
|
||||
db,
|
||||
event.tenant.id,
|
||||
[row.case_id],
|
||||
).get(row.case_id, ()),
|
||||
change_cursor=cursor,
|
||||
)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="cases",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="delete" if deleted else "upsert",
|
||||
reference=reference,
|
||||
source_revision=(document.source_revision if document else cursor),
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource:
|
||||
return CasesSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: CaseRecordRevision,
|
||||
*,
|
||||
identity: CaseIdentity,
|
||||
grants: Sequence[CaseAccessGrant],
|
||||
change_cursor: str | None = None,
|
||||
) -> SearchDocument:
|
||||
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
|
||||
if identity.created_by:
|
||||
tokens.extend(
|
||||
(f"account:{identity.created_by}", f"membership:{identity.created_by}")
|
||||
)
|
||||
prefixes = {
|
||||
"account": "account",
|
||||
"identity": "identity",
|
||||
"group": "group",
|
||||
"role": "role",
|
||||
"function": "function",
|
||||
"function_assignment": "function",
|
||||
}
|
||||
for grant in grants:
|
||||
prefix = prefixes.get(grant.subject_kind)
|
||||
if prefix:
|
||||
tokens.append(f"{prefix}:{grant.subject_id}")
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="cases",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.case_id,
|
||||
title=row.title,
|
||||
url=f"/cases/{quote(row.case_id, safe='')}",
|
||||
summary=f"{identity.case_number} - {row.status_key}",
|
||||
body=row.search_text[:200_000],
|
||||
keywords=(
|
||||
identity.case_number[:200],
|
||||
row.case_type_key[:200],
|
||||
row.status_key[:200],
|
||||
),
|
||||
visibility="restricted",
|
||||
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||
metadata={
|
||||
"case_number": identity.case_number,
|
||||
"case_type_key": row.case_type_key,
|
||||
"status_key": row.status_key,
|
||||
"access_mode": row.access_mode,
|
||||
},
|
||||
source_revision=str(row.revision),
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=row.recorded_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _grants_by_case(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
case_ids: Sequence[str],
|
||||
) -> dict[str, tuple[CaseAccessGrant, ...]]:
|
||||
grouped: dict[str, list[CaseAccessGrant]] = {
|
||||
case_id: [] for case_id in case_ids
|
||||
}
|
||||
if not case_ids:
|
||||
return {}
|
||||
rows = session.scalars(
|
||||
select(CaseAccessGrant).where(
|
||||
CaseAccessGrant.tenant_id == tenant_id,
|
||||
CaseAccessGrant.case_id.in_(tuple(case_ids)),
|
||||
CaseAccessGrant.active.is_(True),
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
grouped.setdefault(row.case_id, []).append(row)
|
||||
return {key: tuple(value) for key, value in grouped.items()}
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Cases search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Cases search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CasesSearchSource",
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"create_cases_search_source",
|
||||
]
|
||||
@@ -331,6 +331,8 @@ def create_case_from_intake(
|
||||
"result_refs": list(plan.result_refs),
|
||||
"required_evidence_types": list(plan.required_evidence_types),
|
||||
"deadline_refs": list(plan.deadline_refs),
|
||||
"remedy_refs": list(plan.remedy_refs),
|
||||
"service_level_refs": list(plan.service_level_refs),
|
||||
},
|
||||
)
|
||||
return create_case(
|
||||
|
||||
@@ -26,6 +26,8 @@ class CaseIntakePlan:
|
||||
result_refs: tuple[str, ...] = ()
|
||||
required_evidence_types: tuple[str, ...] = ()
|
||||
deadline_refs: tuple[str, ...] = ()
|
||||
remedy_refs: tuple[str, ...] = ()
|
||||
service_level_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class CaseServiceIntake:
|
||||
@@ -91,6 +93,8 @@ class CaseServiceIntake:
|
||||
result_refs=_binding_refs(definition, "result"),
|
||||
required_evidence_types=definition.required_evidence_types,
|
||||
deadline_refs=definition.deadline_refs,
|
||||
remedy_refs=definition.remedy_refs,
|
||||
service_level_refs=definition.service_level_refs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user