feat: implement governed cases workspace

This commit is contained in:
2026-08-01 17:48:24 +02:00
parent 122159de16
commit c7821a5cb0
32 changed files with 5699 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
"""GovOPlaN Cases module."""
from govoplan_cases.backend.manifest import get_manifest
__all__ = ["get_manifest"]
+1
View File
@@ -0,0 +1 @@
"""Cases backend contracts."""
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from govoplan_core.auth import has_scope
from govoplan_core.core.modules import AccessDecision
class CaseAclProvider:
"""Expose the tenant-level Case permission boundary to generic consumers."""
resource_type = "case"
def can_read(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "cases:case:read")
def can_write(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "cases:case:update")
def explain(self, principal: object, resource_id: str) -> AccessDecision:
del resource_id
allowed = self.can_read(principal, "")
return AccessDecision(
allowed=allowed,
reason=None if allowed else "Missing scope: cases:case:read",
requirements=("cases:case:read",),
)
__all__ = ["CaseAclProvider"]
+19
View File
@@ -0,0 +1,19 @@
"""Cases database models."""
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
__all__ = [
"CaseAccessGrant",
"CaseIdentity",
"CaseRecordRevision",
"CaseStatusDefinition",
"CaseTimelineEntry",
"CaseTypeDefinition",
]
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class CaseTypeDefinition(Base, TimestampMixin):
__tablename__ = "case_type_definitions"
__table_args__ = (
UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
Index("ix_case_type_catalog", "tenant_id", "active", "label"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
initial_status_key: Mapped[str] = mapped_column(String(120), nullable=False)
allowed_status_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class CaseStatusDefinition(Base, TimestampMixin):
__tablename__ = "case_status_definitions"
__table_args__ = (
UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
Index("ix_case_status_catalog", "tenant_id", "active", "sort_order"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
category: Mapped[str] = mapped_column(String(30), nullable=False, default="open")
terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class CaseIdentity(Base, TimestampMixin):
__tablename__ = "case_identities"
__table_args__ = (
UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
Index("ix_case_identity_catalog", "tenant_id", "case_number"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
case_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseRecordRevision(Base, TimestampMixin):
__tablename__ = "case_record_revisions"
__table_args__ = (
UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
Index("ix_case_record_current", "tenant_id", "case_id", "superseded_at"),
Index("ix_case_record_list", "tenant_id", "status_key", "case_type_key", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(
ForeignKey("case_identities.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("case_record_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
case_type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
access_mode: Mapped[str] = mapped_column(
String(30), nullable=False, default="tenant", index=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseAccessGrant(Base, TimestampMixin):
__tablename__ = "case_access_grants"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
name="uq_case_access_grant_subject",
),
Index(
"ix_case_access_grant_lookup",
"tenant_id",
"case_id",
"active",
"subject_kind",
"subject_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
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)
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)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseTimelineEntry(Base, TimestampMixin):
__tablename__ = "case_timeline_entries"
__table_args__ = (
UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
Index("ix_case_timeline_case", "tenant_id", "case_id", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
case_revision: Mapped[int] = mapped_column(Integer, nullable=False)
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)
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)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = [
"CaseAccessGrant",
"CaseIdentity",
"CaseRecordRevision",
"CaseStatusDefinition",
"CaseTimelineEntry",
"CaseTypeDefinition",
]
+364
View File
@@ -0,0 +1,364 @@
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",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
}
)
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
@dataclass(frozen=True, slots=True)
class CaseGrant:
subject_kind: str
subject_id: str
permissions: tuple[str, ...] = ("read",)
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)
def to_dict(self) -> dict[str, object]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"permissions": list(self.permissions),
}
@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."
)
return cls(
subject_kind=_text(value, "subject_kind"),
subject_id=_text(value, "subject_id"),
permissions=tuple(str(item) for item in raw_permissions),
)
@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"]
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
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.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_cases.backend.party_context import (
CAPABILITY_CASES_PARTY_CONTEXT,
CasePartyContext,
)
from govoplan_cases.backend.acl import CaseAclProvider
from govoplan_cases.backend.db import models as case_models
from govoplan_cases.backend.service_intake import (
CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake,
)
from govoplan_cases.backend.service_launcher import (
CAPABILITY_CASES_SERVICE_LAUNCHER,
CaseServiceLauncher,
)
from govoplan_cases.backend.service import (
CAPABILITY_CASES_REGISTRY,
SqlCaseRegistry,
)
from govoplan_core.db.base import Base
MODULE_ID = "cases"
MODULE_VERSION = "0.1.8"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
ASSIGN_SCOPE = "cases:case:assign"
CLOSE_SCOPE = "cases:case:close"
SHARE_SCOPE = "cases:case:share"
ADMIN_SCOPE = "cases:case:admin"
def _party_context(context: ModuleContext) -> CasePartyContext:
return CasePartyContext(context.registry)
def _service_intake(context: ModuleContext) -> CaseServiceIntake:
del context
return CaseServiceIntake()
def _case_registry(context: ModuleContext) -> SqlCaseRegistry:
del context
return SqlCaseRegistry()
def _service_launcher(context: ModuleContext) -> CaseServiceLauncher:
del context
return CaseServiceLauncher()
def _router(context: ModuleContext):
del context
from govoplan_cases.backend.router import router
return router
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Cases",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
total = (
session.query(case_models.CaseIdentity)
.filter(case_models.CaseIdentity.tenant_id == tenant_id)
.count()
)
open_cases = (
session.query(case_models.CaseRecordRevision)
.filter(
case_models.CaseRecordRevision.tenant_id == tenant_id,
case_models.CaseRecordRevision.superseded_at.is_(None),
case_models.CaseRecordRevision.closed_at.is_(None),
)
.count()
)
return {"cases": total, "open_cases": open_cases}
manifest = ModuleManifest(
id=MODULE_ID,
name="Cases",
version=MODULE_VERSION,
optional_dependencies=(
"access",
"addresses",
"services",
"parties",
"mandates",
"decisions",
"forms_runtime",
"workflow_engine",
),
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."),
),
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),
),
RoleTemplate(
slug="case_reader",
name="Case reader",
description="Read cases and their governed history.",
permissions=(READ_SCOPE,),
),
RoleTemplate(
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),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/cases",
label="Cases",
icon="briefcase-business",
required_any=(READ_SCOPE,),
order=35,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/cases-webui",
routes=(
FrontendRoute(
path="/cases",
component="CasesPage",
required_any=(READ_SCOPE,),
order=35,
),
FrontendRoute(
path="/cases/:caseId",
component="CaseDetailPage",
required_any=(READ_SCOPE,),
order=36,
),
),
nav_items=(
NavItem(
path="/cases",
label="Cases",
icon="briefcase-business",
required_any=(READ_SCOPE,),
order=35,
),
),
view_surfaces=(
ViewSurface(
id="cases.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Cases navigation",
order=10,
),
ViewSurface(
id="cases.list",
module_id=MODULE_ID,
kind="route",
label="Case list",
order=20,
),
ViewSurface(
id="cases.detail",
module_id=MODULE_ID,
kind="route",
label="Case details",
order=30,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="cases.service_intake", version="0.1.0"),
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"),
),
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),
),
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_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
label="Case service intake",
summary="Preserves a governed Service version in a case intake plan.",
contract_version="0.1.0",
),
CAPABILITY_CASES_PARTY_CONTEXT: CapabilityDocumentation(
label="Case party context",
summary="Resolves provider-owned procedure parties or a bounded local compatibility projection.",
contract_version="0.1.0",
),
CAPABILITY_CASES_REGISTRY: CapabilityDocumentation(
label="Case registry",
summary="Persists tenant-scoped case identities, immutable revisions, and lifecycle events.",
contract_version="0.1.0",
),
CAPABILITY_CASES_SERVICE_LAUNCHER: CapabilityDocumentation(
label="Case service launcher",
summary="Starts a replay-safe case from an exact available Service revision.",
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
case_models.CaseTimelineEntry,
case_models.CaseAccessGrant,
case_models.CaseRecordRevision,
case_models.CaseIdentity,
case_models.CaseTypeDefinition,
case_models.CaseStatusDefinition,
label="Cases",
),
retirement_notes="Destructive retirement requires a database snapshot and removes case identities, immutable revisions, catalogs, and timeline evidence.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
case_models.CaseIdentity,
case_models.CaseRecordRevision,
case_models.CaseTimelineEntry,
case_models.CaseAccessGrant,
case_models.CaseTypeDefinition,
case_models.CaseStatusDefinition,
label="Cases",
),
),
resource_acl_providers=(CaseAclProvider(),),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="cases.institutional-context",
title="Case institutional context",
summary="Cases retain service and party references without taking ownership of institutional definitions or subject masters.",
body=(
"Case types and statuses are tenant configuration. Every create or update writes "
"an immutable OCC-guarded revision and a replay-safe timeline event. Service "
"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."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
),
),
architecture=ModuleArchitectureDeclaration(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_institutional_consumers.py",
summary="Proves versioned service intake and optional procedure-party resolution.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CONCEPT.md",
summary="Defines Cases ownership and optional institutional providers.",
),
),
known_limits=(
"The first Cases workspace covers list, detail, status/title revision, history, and timeline; richer procedure-specific panels remain module contributions.",
"The compatibility party path deliberately excludes representation lifecycle ownership.",
),
owned_concepts=("case identity", "case lifecycle", "case-local links"),
non_owned_concepts=(
"institutional service definition",
"party subject master",
"representation power lifecycle",
"formal decision lifecycle",
),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
migration=("docs/CONCEPT.md",),
recovery=("docs/CONCEPT.md",),
security=("docs/CONCEPT.md",),
operations=("docs/CONCEPT.md",),
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Cases Alembic revisions."""
@@ -0,0 +1 @@
"""Cases migration revisions."""
@@ -0,0 +1,139 @@
"""v0.1.8 Cases persistent lifecycle baseline.
Revision ID: c7a8e9f0b1d2
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7a8e9f0b1d2"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"case_type_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("type_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("initial_status_key", sa.String(length=120), nullable=False),
sa.Column("allowed_status_keys", sa.JSON(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_type_definitions")),
sa.UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
)
op.create_index(op.f("ix_case_type_definitions_tenant_id"), "case_type_definitions", ["tenant_id"], unique=False)
op.create_index(op.f("ix_case_type_definitions_type_key"), "case_type_definitions", ["type_key"], unique=False)
op.create_index(op.f("ix_case_type_definitions_active"), "case_type_definitions", ["active"], unique=False)
op.create_index("ix_case_type_catalog", "case_type_definitions", ["tenant_id", "active", "label"], unique=False)
op.create_table(
"case_status_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("category", sa.String(length=30), nullable=False),
sa.Column("terminal", sa.Boolean(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_status_definitions")),
sa.UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
)
for column in ("tenant_id", "status_key", "terminal", "active"):
op.create_index(op.f(f"ix_case_status_definitions_{column}"), "case_status_definitions", [column], unique=False)
op.create_index("ix_case_status_catalog", "case_status_definitions", ["tenant_id", "active", "sort_order"], unique=False)
op.create_table(
"case_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("case_number", sa.String(length=255), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_identities")),
sa.UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
sa.UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
)
for column in ("tenant_id", "case_id", "case_number", "created_by"):
op.create_index(op.f(f"ix_case_identities_{column}"), "case_identities", [column], unique=False)
op.create_index("ix_case_identity_catalog", "case_identities", ["tenant_id", "case_number"], unique=False)
op.create_table(
"case_record_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("case_type_key", sa.String(length=120), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["identity_id"], ["case_identities.id"], name=op.f("fk_case_record_revisions_identity_id_case_identities"), ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["previous_revision_id"], ["case_record_revisions.id"], name=op.f("fk_case_record_revisions_previous_revision_id_case_record_revisions"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_record_revisions")),
sa.UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
)
for column in ("tenant_id", "case_id", "identity_id", "previous_revision_id", "case_type_key", "status_key", "opened_at", "deadline_at", "closed_at", "recorded_at", "superseded_at", "changed_by"):
op.create_index(op.f(f"ix_case_record_revisions_{column}"), "case_record_revisions", [column], unique=False)
op.create_index("ix_case_record_current", "case_record_revisions", ["tenant_id", "case_id", "superseded_at"], unique=False)
op.create_index("ix_case_record_list", "case_record_revisions", ["tenant_id", "status_key", "case_type_key", "recorded_at"], unique=False)
op.create_table(
"case_timeline_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("case_revision", sa.Integer(), nullable=False),
sa.Column("summary", sa.String(length=500), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("audit_event_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_timeline_entries")),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
)
for column in ("tenant_id", "case_id", "event_id", "event_type", "occurred_at", "actor_id", "audit_event_id"):
op.create_index(op.f(f"ix_case_timeline_entries_{column}"), "case_timeline_entries", [column], unique=False)
op.create_index("ix_case_timeline_case", "case_timeline_entries", ["tenant_id", "case_id", "occurred_at"], unique=False)
def downgrade() -> None:
op.drop_table("case_timeline_entries")
op.drop_table("case_record_revisions")
op.drop_table("case_identities")
op.drop_table("case_status_definitions")
op.drop_table("case_type_definitions")
@@ -0,0 +1,87 @@
"""v0.1.14 case object sharing.
Revision ID: f6d3a8b1c4e7
Revises: c7a8e9f0b1d2
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f6d3a8b1c4e7"
down_revision = "c7a8e9f0b1d2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"case_record_revisions",
sa.Column(
"access_mode",
sa.String(length=30),
nullable=False,
server_default="tenant",
),
)
op.create_index(
op.f("ix_case_record_revisions_access_mode"),
"case_record_revisions",
["access_mode"],
unique=False,
)
op.create_table(
"case_access_grants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("subject_kind", sa.String(length=40), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("permissions", sa.JSON(), nullable=False),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("source_revision", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_access_grants")),
sa.UniqueConstraint(
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
name="uq_case_access_grant_subject",
),
)
for column in (
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
"active",
"created_by",
):
op.create_index(
op.f(f"ix_case_access_grants_{column}"),
"case_access_grants",
[column],
unique=False,
)
op.create_index(
"ix_case_access_grant_lookup",
"case_access_grants",
["tenant_id", "case_id", "active", "subject_kind", "subject_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("case_access_grants")
op.drop_index(
op.f("ix_case_record_revisions_access_mode"),
table_name="case_record_revisions",
)
op.drop_column("case_record_revisions", "access_mode")
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from govoplan_core.core.institutional import (
CAPABILITY_PARTY_RESOLVER,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
PartyRepresentation,
PartyResolver,
PartySubjectReference,
ProcedureParty,
TemporalRevision,
)
CAPABILITY_CASES_PARTY_CONTEXT = "cases.party_context"
CasePartySource = Literal["provider", "compatibility"]
@dataclass(frozen=True, slots=True)
class CasePartyCompatibilityRecord:
party_id: str
role: str
subject: PartySubjectReference
valid_from: datetime
valid_to: datetime | None = None
preferred_channels: tuple[str, ...] = ()
permitted_channels: tuple[str, ...] = ()
delivery_recipient: bool = False
contact_snapshot_refs: tuple[str, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
@dataclass(frozen=True, slots=True)
class CasePartySet:
case_ref: InstitutionalReference
effective_at: datetime
source: CasePartySource
parties: tuple[ProcedureParty, ...]
@dataclass(frozen=True, slots=True)
class CasePartyDeliveryTarget:
party_ref: InstitutionalReference
subject: PartySubjectReference
role: str
channel: str
contact_snapshot_refs: tuple[str, ...]
represented_party_refs: tuple[InstitutionalReference, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
class CasePartyContext:
"""Resolve case-local participation without copying subject master data."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def resolve(
self,
session: object,
principal: object,
*,
case_ref: InstitutionalReference,
effective_at: datetime,
compatibility: tuple[CasePartyCompatibilityRecord, ...] = (),
) -> CasePartySet:
if case_ref.kind != "case":
raise InstitutionalContextError(
"Case party resolution requires a case reference."
)
_require_aware(effective_at)
provider = _capability(self._registry, CAPABILITY_PARTY_RESOLVER)
if isinstance(provider, PartyResolver):
candidates = tuple(
provider.list_procedure_parties(
session,
principal,
procedure_ref=case_ref,
effective_at=effective_at,
)
)
source: CasePartySource = "provider"
else:
candidates = tuple(
_compatibility_party(case_ref, item) for item in compatibility
)
source = "compatibility"
effective = tuple(
item
for item in candidates
if item.status == "active" and item.temporal.effective_at(effective_at)
)
for item in effective:
if not _same_object(item.procedure_ref, case_ref):
raise InstitutionalContextError(
"Party provider returned a party for another procedure."
)
keys = tuple(
(
item.role,
item.subject.kind,
item.subject.provider,
item.subject.subject_id,
)
for item in effective
)
if len(keys) != len(set(keys)):
raise InstitutionalContextError(
"Party resolution returned conflicting active role assignments."
)
return CasePartySet(
case_ref=case_ref,
effective_at=effective_at,
source=source,
parties=effective,
)
def delivery_targets(
self,
party_set: CasePartySet,
*,
channel: str,
) -> tuple[CasePartyDeliveryTarget, ...]:
if not channel.strip():
raise InstitutionalContextError("Delivery channel is required.")
targets: list[CasePartyDeliveryTarget] = []
for party in party_set.parties:
if not party.delivery_recipient or channel not in party.permitted_channels:
continue
if not party.contact_snapshot_refs:
raise InstitutionalContextError(
"A case delivery target requires frozen contact snapshots."
)
represented = tuple(
item.represented_party_ref
for item in party.representations
if _representation_effective(
item,
effective_at=party_set.effective_at,
action="receive",
)
)
targets.append(
CasePartyDeliveryTarget(
party_ref=party.reference,
subject=party.subject,
role=party.role,
channel=channel,
contact_snapshot_refs=party.contact_snapshot_refs,
represented_party_refs=represented,
evidence=party.evidence,
)
)
return tuple(targets)
def _compatibility_party(
case_ref: InstitutionalReference,
item: CasePartyCompatibilityRecord,
) -> ProcedureParty:
if item.subject.tenant_id != case_ref.tenant_id:
raise InstitutionalContextError(
"Compatibility party subject belongs to another tenant."
)
return ProcedureParty(
reference=InstitutionalReference(
kind="party",
owner_module="cases",
object_id=item.party_id,
tenant_id=case_ref.tenant_id,
version="compatibility-1",
valid_at=item.valid_from,
),
procedure_ref=case_ref,
role=item.role,
subject=item.subject,
temporal=TemporalRevision(
revision="compatibility-1",
valid_from=item.valid_from,
valid_to=item.valid_to,
recorded_at=item.valid_from,
change_reason="Cases compatibility party projection.",
),
preferred_channels=item.preferred_channels,
permitted_channels=item.permitted_channels,
delivery_recipient=item.delivery_recipient,
contact_snapshot_refs=item.contact_snapshot_refs,
evidence=item.evidence,
)
def _representation_effective(
representation: PartyRepresentation,
*,
effective_at: datetime,
action: str,
) -> bool:
return (
action in representation.permitted_actions
and representation.temporal.effective_at(effective_at)
and (
representation.revoked_at is None
or representation.revoked_at > effective_at
)
)
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:
if value.tzinfo is None or value.utcoffset() is None:
raise InstitutionalContextError(
"Case party effective time 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)
__all__ = [
"CAPABILITY_CASES_PARTY_CONTEXT",
"CasePartyCompatibilityRecord",
"CasePartyContext",
"CasePartyDeliveryTarget",
"CasePartySet",
]
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_core.db.session import get_session
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
)
from govoplan_core.core.runtime import get_registry
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.manifest import (
ADMIN_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
CREATE_SCOPE,
READ_SCOPE,
SHARE_SCOPE,
UPDATE_SCOPE,
)
from govoplan_cases.backend.schemas import (
CaseHistoryResponse,
CaseListResponse,
CaseStatusWriteRequest,
CaseTimelineResponse,
CaseTypeWriteRequest,
CaseUpdateRequest,
CaseWriteRequest,
)
from govoplan_cases.backend.service import (
CaseStoreError,
can_access_case,
case_history,
case_timeline,
create_case,
get_case,
list_case_catalog,
list_cases,
update_case,
upsert_case_status,
upsert_case_type,
)
router = APIRouter(prefix="/cases", tags=["cases"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
if isinstance(exc, LookupError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif any(word in lowered for word in ("conflict", "already", "stale")):
code = 409
else:
code = 400
return HTTPException(status_code=code, detail=message)
@router.get("/catalog", response_model=dict[str, list[dict[str, Any]]])
def api_case_catalog(
include_inactive: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, list[dict[str, Any]]]:
_require(principal, ADMIN_SCOPE if include_inactive else READ_SCOPE)
return list_case_catalog(
session,
principal,
include_inactive=include_inactive,
)
@router.put("/catalog/statuses/{status_key}", response_model=dict[str, Any])
def api_upsert_case_status(
status_key: str,
payload: CaseStatusWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
if status_key != payload.status_key:
raise HTTPException(
status_code=400,
detail="Case status path and payload keys must match.",
)
try:
row = upsert_case_status(session, principal, **payload.model_dump())
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return {
"status_key": row.status_key,
"label": row.label,
"category": row.category,
"terminal": row.terminal,
"sort_order": row.sort_order,
"active": row.active,
"revision": row.revision,
}
@router.put("/catalog/types/{type_key}", response_model=dict[str, Any])
def api_upsert_case_type(
type_key: str,
payload: CaseTypeWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
if type_key != payload.type_key:
raise HTTPException(
status_code=400,
detail="Case type path and payload keys must match.",
)
try:
row = upsert_case_type(session, principal, **payload.model_dump())
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return {
"type_key": row.type_key,
"label": row.label,
"description": row.description,
"initial_status_key": row.initial_status_key,
"allowed_status_keys": list(row.allowed_status_keys or ()),
"active": row.active,
"revision": row.revision,
}
@router.get("", response_model=CaseListResponse)
def api_list_cases(
query: str = "",
status_key: list[str] | None = Query(default=None),
case_type_key: list[str] | None = Query(default=None),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseListResponse:
_require(principal, READ_SCOPE)
try:
items, total = list_cases(
session,
principal,
query=query,
status_keys=status_key,
case_type_keys=case_type_key,
offset=offset,
limit=limit,
)
except CaseStoreError as exc:
raise _error(exc) from exc
return CaseListResponse(
cases=[item.to_dict() for item in items],
total=total,
offset=offset,
limit=limit,
)
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_case(
payload: CaseWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, CREATE_SCOPE)
try:
record = CaseRecord.from_mapping(payload.record)
if record.access_mode == "restricted" or record.access_grants:
_require(principal, SHARE_SCOPE)
item = create_case(
session,
principal,
record=record,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.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),
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)
if item is None:
raise HTTPException(status_code=404, detail="Case not found")
return item.to_dict()
@router.patch("/{case_id}", response_model=dict[str, Any])
def api_update_case(
case_id: str,
payload: CaseUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
fields = payload.model_fields_set
if "assignment_refs" in fields:
_require(principal, ASSIGN_SCOPE)
if fields & {"access_mode", "access_grants"}:
_require(principal, SHARE_SCOPE)
changes = _update_changes(payload)
if "status_key" in changes:
catalog = list_case_catalog(session, principal)
terminal = {
str(item["status_key"]): bool(item["terminal"])
for item in catalog["statuses"]
}
if terminal.get(str(changes["status_key"]), False):
_require(principal, CLOSE_SCOPE)
try:
item = update_case(
session,
principal,
case_id=case_id,
expected_revision=payload.expected_revision,
changes=changes,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (
CaseStoreError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get(
"/{case_id}/share-target-options",
response_model=ReferenceOptionListResponse,
)
def api_case_share_target_options(
case_id: str,
target_type: Literal["user", "group"],
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
_require(principal, SHARE_SCOPE)
if not can_access_case(
session,
principal,
case_id=case_id,
permission="share",
):
raise HTTPException(status_code=403, detail="Case share access is denied")
try:
page = access_scope_reference_page(
get_registry(),
principal,
scope_type=target_type,
reference_kind="user" if target_type == "user" else "group",
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=True,
session=session,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options],
provider_available=access_scope_reference_provider_available(get_registry()),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.get("/{case_id}/history", response_model=CaseHistoryResponse)
def api_case_history(
case_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseHistoryResponse:
_require(principal, READ_SCOPE)
return CaseHistoryResponse(
revisions=[
item.to_dict()
for item in case_history(
session,
principal,
case_id=case_id,
limit=limit,
)
]
)
@router.get("/{case_id}/timeline", response_model=CaseTimelineResponse)
def api_case_timeline(
case_id: str,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseTimelineResponse:
_require(principal, READ_SCOPE)
return CaseTimelineResponse(
entries=list(
case_timeline(
session,
principal,
case_id=case_id,
limit=limit,
)
)
)
def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
excluded = {
"expected_revision",
"recorded_at",
"change_reason",
"idempotency_key",
}
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
if key in raw:
raw[key] = tuple(
InstitutionalReference.from_mapping(item)
for item in (raw[key] or ())
)
if "access_grants" in raw:
raw["access_grants"] = tuple(
CaseGrant.from_mapping(item) for item in (raw["access_grants"] or ())
)
if "evidence_refs" in raw:
raw["evidence_refs"] = tuple(
EvidenceReference.from_mapping(item)
for item in (raw["evidence_refs"] or ())
)
if "metadata" in raw and raw["metadata"] is None:
raw["metadata"] = {}
return raw
__all__ = ["router"]
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class CaseStatusWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
status_key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
category: str = Field(default="open", max_length=30)
terminal: bool = False
sort_order: int = Field(default=100, ge=-10_000, le=10_000)
active: bool = True
expected_revision: int | None = Field(default=None, ge=1)
class CaseTypeWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type_key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4_000)
initial_status_key: str = Field(min_length=1, max_length=120)
allowed_status_keys: list[str] = Field(default_factory=list, max_length=100)
active: bool = True
expected_revision: int | None = Field(default=None, ge=1)
class CaseWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
record: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
class CaseGrantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_kind: Literal[
"account",
"identity",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
]
subject_id: str = Field(min_length=1, max_length=255)
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
default_factory=lambda: ["read"],
min_length=1,
max_length=4,
)
class CaseUpdateRequest(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)
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
access_grants: list[CaseGrantRequest] | None = Field(
default=None,
max_length=500,
)
party_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
assignment_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
evidence_refs: list[dict[str, Any]] | None = Field(default=None, max_length=1_000)
decision_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
record_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
deadline_at: datetime | None = None
closed_at: datetime | None = None
metadata: dict[str, Any] | None = None
class CaseListResponse(BaseModel):
cases: list[dict[str, Any]]
total: int
offset: int
limit: int
class CaseHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
class CaseTimelineResponse(BaseModel):
entries: list[dict[str, Any]]
__all__ = [
"CaseGrantRequest",
"CaseHistoryResponse",
"CaseListResponse",
"CaseStatusWriteRequest",
"CaseTimelineResponse",
"CaseTypeWriteRequest",
"CaseUpdateRequest",
"CaseWriteRequest",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalContextError,
InstitutionalReference,
ServiceDefinition,
TemporalRevision,
)
CAPABILITY_CASES_SERVICE_INTAKE = "cases.service_intake"
@dataclass(frozen=True, slots=True)
class CaseIntakePlan:
case_ref: InstitutionalReference
service_ref: InstitutionalReference
case_type_ref: str
context: GovernedContextEnvelope
form_refs: tuple[str, ...] = ()
workflow_refs: tuple[str, ...] = ()
result_refs: tuple[str, ...] = ()
required_evidence_types: tuple[str, ...] = ()
deadline_refs: tuple[str, ...] = ()
class CaseServiceIntake:
"""Translate a provider-owned Service definition into a case intake plan."""
def plan(
self,
definition: ServiceDefinition,
*,
case_id: str,
effective_at: datetime,
) -> CaseIntakePlan:
if definition.publication_state != "published":
raise InstitutionalContextError(
"Only a published institutional service can start a case."
)
if not definition.temporal.effective_at(effective_at):
raise InstitutionalContextError(
"The institutional service is not effective at case intake time."
)
case_bindings = tuple(
item for item in definition.bindings if item.kind == "case"
)
if len(case_bindings) != 1:
raise InstitutionalContextError(
"Case intake requires exactly one case binding in the service definition."
)
case_ref = InstitutionalReference(
kind="case",
owner_module="cases",
object_id=case_id,
tenant_id=definition.reference.tenant_id,
version="1",
valid_at=effective_at,
)
temporal = TemporalRevision(
revision="1",
valid_from=effective_at,
recorded_at=effective_at,
change_reason=(
f"Case intake from service {definition.key}@"
f"{definition.temporal.revision}."
),
)
context = GovernedContextEnvelope(
tenant_id=definition.reference.tenant_id,
temporal=temporal,
organization_unit_ref=definition.responsible_organization_ref,
function_ref=definition.responsible_function_ref,
mandate_ref=definition.mandate_ref,
jurisdiction_refs=definition.jurisdiction_refs,
service_ref=definition.reference,
case_ref=case_ref,
legal_bases=definition.legal_bases,
)
return CaseIntakePlan(
case_ref=case_ref,
service_ref=definition.reference,
case_type_ref=case_bindings[0].reference,
context=context,
form_refs=_binding_refs(definition, "form"),
workflow_refs=_binding_refs(definition, "workflow"),
result_refs=_binding_refs(definition, "result"),
required_evidence_types=definition.required_evidence_types,
deadline_refs=definition.deadline_refs,
)
def _binding_refs(
definition: ServiceDefinition,
kind: str,
) -> tuple[str, ...]:
return tuple(item.reference for item in definition.bindings if item.kind == kind)
__all__ = [
"CAPABILITY_CASES_SERVICE_INTAKE",
"CaseIntakePlan",
"CaseServiceIntake",
]
@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Mapping
import re
from urllib.parse import quote
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
InstitutionalContextError,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
)
from govoplan_cases.backend.service import create_case_from_intake, get_case
from govoplan_cases.backend.service_intake import CaseServiceIntake
CAPABILITY_CASES_SERVICE_LAUNCHER = "cases.service_launcher"
_CASE_LAUNCH_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/cases/service-launch/v1",
)
class CaseServiceLauncher:
"""Start a replay-safe case from an exact institutional Service revision."""
def launch_service(
self,
session: object,
principal: object,
*,
definition: ServiceDefinition,
request: ServiceLaunchRequest,
) -> ServiceLaunchResult:
if not isinstance(session, Session):
raise InstitutionalContextError(
"Case service launch requires a SQLAlchemy session."
)
if request.service_ref != definition.reference:
raise InstitutionalContextError(
"Case service launch must use the requested exact Service revision."
)
if request.binding.kind != "case" or request.binding not in definition.bindings:
raise InstitutionalContextError(
"Case service launch requires a case binding from the Service definition."
)
case_id = str(
uuid.uuid5(
_CASE_LAUNCH_NAMESPACE,
":".join(
(
definition.reference.tenant_id,
definition.reference.object_id,
definition.reference.version or "",
request.idempotency_key,
)
),
)
)
replayed = get_case(session, principal, case_id=case_id) is not None
plan = CaseServiceIntake().plan(
definition,
case_id=case_id,
effective_at=request.requested_at,
)
title = _parameter_text(
request.parameters,
"title",
default=definition.title,
maximum=500,
)
case_number = _parameter_text(
request.parameters,
"case_number",
default=_default_case_number(definition, case_id),
maximum=255,
)
status_key = _optional_parameter_text(
request.parameters,
"status_key",
maximum=120,
)
item = create_case_from_intake(
session,
principal,
plan=plan,
case_number=case_number,
title=title,
status_key=status_key,
opened_at=request.requested_at,
recorded_at=request.requested_at,
change_reason=(
f"Started from service {definition.key}@"
f"{definition.reference.version}."
),
idempotency_key=request.idempotency_key,
metadata={
"service_launch_binding": request.binding.reference,
"service_launch_capability": CAPABILITY_CASES_SERVICE_LAUNCHER,
},
)
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=item.reference,
href=f"/cases/{quote(item.reference.object_id, safe='')}",
replayed=replayed,
metadata={
"case_number": item.case_number,
"case_revision": item.revision,
},
)
def _default_case_number(definition: ServiceDefinition, case_id: str) -> str:
prefix = re.sub(r"[^A-Za-z0-9]+", "-", definition.key).strip("-")
return f"{(prefix or 'CASE')[:40].upper()}-{case_id[:8].upper()}"
def _parameter_text(
parameters: Mapping[str, object],
key: str,
*,
default: str,
maximum: int,
) -> str:
value = str(parameters.get(key) or default).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
def _optional_parameter_text(
parameters: Mapping[str, object],
key: str,
*,
maximum: int,
) -> str | None:
raw = parameters.get(key)
if raw is None:
return None
value = str(raw).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
__all__ = ["CAPABILITY_CASES_SERVICE_LAUNCHER", "CaseServiceLauncher"]
+1
View File
@@ -0,0 +1 @@