395 lines
14 KiB
Python
395 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from govoplan_core.core.institutional import (
|
|
EvidenceReference,
|
|
GovernedContextEnvelope,
|
|
InstitutionalContextError,
|
|
InstitutionalReference,
|
|
)
|
|
|
|
|
|
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)
|
|
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:
|
|
raise InstitutionalContextError("Case access subject kind is invalid.")
|
|
if not self.subject_id.strip():
|
|
raise InstitutionalContextError("Case access subject id is required.")
|
|
normalized = tuple(dict.fromkeys(self.permissions))
|
|
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
|
|
def from_mapping(cls, value: Mapping[str, object]) -> "CaseGrant":
|
|
raw_permissions = value.get("permissions", ["read"])
|
|
if not isinstance(raw_permissions, (list, tuple)):
|
|
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),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CaseRecord:
|
|
reference: InstitutionalReference
|
|
case_number: str
|
|
case_type_key: str
|
|
status_key: str
|
|
title: str
|
|
context: GovernedContextEnvelope
|
|
opened_at: datetime
|
|
recorded_at: datetime
|
|
change_reason: str
|
|
access_mode: str = "tenant"
|
|
access_grants: tuple[CaseGrant, ...] = ()
|
|
service_ref: InstitutionalReference | None = None
|
|
party_refs: tuple[InstitutionalReference, ...] = ()
|
|
assignment_refs: tuple[InstitutionalReference, ...] = ()
|
|
evidence_refs: tuple[EvidenceReference, ...] = ()
|
|
decision_refs: tuple[InstitutionalReference, ...] = ()
|
|
record_refs: tuple[InstitutionalReference, ...] = ()
|
|
deadline_at: datetime | None = None
|
|
closed_at: datetime | None = None
|
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.reference.kind != "case" or self.reference.owner_module != "cases":
|
|
raise InstitutionalContextError(
|
|
"Case records require a Cases-owned case reference."
|
|
)
|
|
if not self.reference.version or not self.reference.version.isdigit():
|
|
raise InstitutionalContextError(
|
|
"Case references require a positive integer revision."
|
|
)
|
|
if int(self.reference.version) < 1:
|
|
raise InstitutionalContextError(
|
|
"Case references require a positive integer revision."
|
|
)
|
|
for value, label in (
|
|
(self.case_number, "Case number"),
|
|
(self.case_type_key, "Case type"),
|
|
(self.status_key, "Case status"),
|
|
(self.title, "Case title"),
|
|
(self.change_reason, "Case change reason"),
|
|
):
|
|
if not value.strip():
|
|
raise InstitutionalContextError(f"{label} is required.")
|
|
if self.access_mode not in {"tenant", "restricted"}:
|
|
raise InstitutionalContextError("Unsupported case access mode.")
|
|
grant_keys = {
|
|
(item.subject_kind, item.subject_id) for item in self.access_grants
|
|
}
|
|
if len(grant_keys) != len(self.access_grants):
|
|
raise InstitutionalContextError("Case access grants must be unique.")
|
|
for value, label in (
|
|
(self.opened_at, "Case opened_at"),
|
|
(self.recorded_at, "Case recorded_at"),
|
|
(self.deadline_at, "Case deadline_at"),
|
|
(self.closed_at, "Case closed_at"),
|
|
):
|
|
_require_aware(value, label)
|
|
if self.closed_at is not None and self.closed_at < self.opened_at:
|
|
raise InstitutionalContextError(
|
|
"Case closed_at cannot precede opened_at."
|
|
)
|
|
tenant_id = self.reference.tenant_id
|
|
if self.context.tenant_id != tenant_id:
|
|
raise InstitutionalContextError(
|
|
"Case institutional context belongs to another tenant."
|
|
)
|
|
if self.context.case_ref is not None and not _same_object(
|
|
self.context.case_ref,
|
|
self.reference,
|
|
):
|
|
raise InstitutionalContextError(
|
|
"Case institutional context references another case."
|
|
)
|
|
if self.service_ref is not None and self.service_ref.kind != "service":
|
|
raise InstitutionalContextError(
|
|
"Case service_ref must identify an institutional Service."
|
|
)
|
|
_validate_references(
|
|
tenant_id,
|
|
self.party_refs,
|
|
expected_kinds={"party"},
|
|
label="Case parties",
|
|
)
|
|
_validate_references(
|
|
tenant_id,
|
|
self.assignment_refs,
|
|
expected_kinds={
|
|
"function",
|
|
"function_assignment",
|
|
"organization_unit",
|
|
"work_item",
|
|
},
|
|
label="Case assignments",
|
|
)
|
|
_validate_references(
|
|
tenant_id,
|
|
self.decision_refs,
|
|
expected_kinds={"decision"},
|
|
label="Case decisions",
|
|
)
|
|
_validate_references(
|
|
tenant_id,
|
|
self.record_refs,
|
|
expected_kinds={"record"},
|
|
label="Case records",
|
|
)
|
|
references = (
|
|
(self.service_ref,) if self.service_ref is not None else ()
|
|
)
|
|
if any(item.tenant_id != tenant_id for item in references):
|
|
raise InstitutionalContextError(
|
|
"Case references cannot cross tenants."
|
|
)
|
|
if any(item.tenant_id != tenant_id for item in self.evidence_refs):
|
|
raise InstitutionalContextError(
|
|
"Case evidence cannot cross tenants."
|
|
)
|
|
if len(self.metadata) > 100:
|
|
raise InstitutionalContextError(
|
|
"Case metadata is limited to 100 entries."
|
|
)
|
|
|
|
@property
|
|
def revision(self) -> int:
|
|
return int(self.reference.version or "0")
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"reference": self.reference.to_dict(),
|
|
"revision": self.revision,
|
|
"case_number": self.case_number,
|
|
"case_type_key": self.case_type_key,
|
|
"status_key": self.status_key,
|
|
"title": self.title,
|
|
"access_mode": self.access_mode,
|
|
"access_grants": [item.to_dict() for item in self.access_grants],
|
|
"context": self.context.to_dict(),
|
|
"service_ref": self.service_ref.to_dict() if self.service_ref else None,
|
|
"party_refs": [item.to_dict() for item in self.party_refs],
|
|
"assignment_refs": [item.to_dict() for item in self.assignment_refs],
|
|
"evidence_refs": [item.to_dict() for item in self.evidence_refs],
|
|
"decision_refs": [item.to_dict() for item in self.decision_refs],
|
|
"record_refs": [item.to_dict() for item in self.record_refs],
|
|
"opened_at": self.opened_at.isoformat(),
|
|
"recorded_at": self.recorded_at.isoformat(),
|
|
"deadline_at": self.deadline_at.isoformat() if self.deadline_at else None,
|
|
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
|
|
"change_reason": self.change_reason,
|
|
"metadata": dict(self.metadata),
|
|
}
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, object]) -> "CaseRecord":
|
|
reference = _mapping(value, "reference")
|
|
context = _mapping(value, "context")
|
|
service_ref = value.get("service_ref")
|
|
metadata = value.get("metadata")
|
|
if metadata is None:
|
|
metadata = {}
|
|
if not isinstance(metadata, Mapping):
|
|
raise InstitutionalContextError("Case metadata must be an object.")
|
|
return cls(
|
|
reference=InstitutionalReference.from_mapping(reference),
|
|
case_number=_text(value, "case_number"),
|
|
case_type_key=_text(value, "case_type_key"),
|
|
status_key=_text(value, "status_key"),
|
|
title=_text(value, "title"),
|
|
access_mode=str(value.get("access_mode") or "tenant"),
|
|
access_grants=tuple(
|
|
CaseGrant.from_mapping(item)
|
|
for item in _items(value.get("access_grants"), "Case access grants")
|
|
),
|
|
context=GovernedContextEnvelope.from_mapping(context),
|
|
service_ref=(
|
|
InstitutionalReference.from_mapping(service_ref)
|
|
if isinstance(service_ref, Mapping)
|
|
else None
|
|
),
|
|
party_refs=_institutional_references(value.get("party_refs")),
|
|
assignment_refs=_institutional_references(
|
|
value.get("assignment_refs")
|
|
),
|
|
evidence_refs=_evidence_references(value.get("evidence_refs")),
|
|
decision_refs=_institutional_references(value.get("decision_refs")),
|
|
record_refs=_institutional_references(value.get("record_refs")),
|
|
opened_at=_datetime(value, "opened_at"),
|
|
recorded_at=_datetime(value, "recorded_at"),
|
|
deadline_at=_optional_datetime(value.get("deadline_at")),
|
|
closed_at=_optional_datetime(value.get("closed_at")),
|
|
change_reason=_text(value, "change_reason"),
|
|
metadata=dict(metadata),
|
|
)
|
|
|
|
|
|
def _validate_references(
|
|
tenant_id: str,
|
|
references: tuple[InstitutionalReference, ...],
|
|
*,
|
|
expected_kinds: set[str],
|
|
label: str,
|
|
) -> None:
|
|
if any(item.tenant_id != tenant_id for item in references):
|
|
raise InstitutionalContextError(f"{label} cannot cross tenants.")
|
|
invalid = {item.kind for item in references} - expected_kinds
|
|
if invalid:
|
|
raise InstitutionalContextError(
|
|
f"{label} contain unsupported reference kinds: "
|
|
+ ", ".join(sorted(invalid))
|
|
)
|
|
|
|
|
|
def _same_object(
|
|
left: InstitutionalReference,
|
|
right: InstitutionalReference,
|
|
) -> bool:
|
|
return (
|
|
left.kind,
|
|
left.owner_module,
|
|
left.object_id,
|
|
left.tenant_id,
|
|
) == (
|
|
right.kind,
|
|
right.owner_module,
|
|
right.object_id,
|
|
right.tenant_id,
|
|
)
|
|
|
|
|
|
def _require_aware(value: datetime | None, label: str) -> None:
|
|
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
|
raise InstitutionalContextError(f"{label} must include a timezone.")
|
|
|
|
|
|
def _mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]:
|
|
item = value.get(key)
|
|
if not isinstance(item, Mapping):
|
|
raise InstitutionalContextError(f"Case {key} must be an object.")
|
|
return item
|
|
|
|
|
|
def _text(value: Mapping[str, object], key: str) -> str:
|
|
item = str(value.get(key) or "").strip()
|
|
if not item:
|
|
raise InstitutionalContextError(f"Case {key} is required.")
|
|
return item
|
|
|
|
|
|
def _datetime(value: Mapping[str, object], key: str) -> datetime:
|
|
item = _optional_datetime(value.get(key))
|
|
if item is None:
|
|
raise InstitutionalContextError(f"Case {key} is required.")
|
|
return item
|
|
|
|
|
|
def _optional_datetime(value: object) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
result = value
|
|
else:
|
|
try:
|
|
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise InstitutionalContextError("Case timestamp is invalid.") from exc
|
|
_require_aware(result, "Case timestamp")
|
|
return result
|
|
|
|
|
|
def _items(value: object, label: str) -> tuple[Mapping[str, object], ...]:
|
|
if value is None:
|
|
return ()
|
|
if not isinstance(value, (list, tuple)) or any(
|
|
not isinstance(item, Mapping) for item in value
|
|
):
|
|
raise InstitutionalContextError(f"{label} must be a list of objects.")
|
|
return tuple(value) # type: ignore[return-value]
|
|
|
|
|
|
def _institutional_references(
|
|
value: object,
|
|
) -> tuple[InstitutionalReference, ...]:
|
|
return tuple(
|
|
InstitutionalReference.from_mapping(item)
|
|
for item in _items(value, "Case references")
|
|
)
|
|
|
|
|
|
def _evidence_references(value: object) -> tuple[EvidenceReference, ...]:
|
|
return tuple(
|
|
EvidenceReference.from_mapping(item)
|
|
for item in _items(value, "Case evidence")
|
|
)
|
|
|
|
|
|
__all__ = ["CaseGrant", "CaseRecord"]
|