Integrate formal decisions with cases

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent 2f030e56a9
commit 23f5091a2b
17 changed files with 2048 additions and 23 deletions
+478
View File
@@ -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",
]
+78 -3
View File
@@ -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",
],
+85 -1
View File
@@ -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,
+14
View File
@@ -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",
+283
View File
@@ -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",
]
+2
View File
@@ -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,
)