feat(cases): expose exact revisions for records filing
This commit is contained in:
@@ -166,6 +166,13 @@ correction, revocation, or remedy semantics.
|
||||
Evidence links should store only stable module/resource references and display
|
||||
metadata snapshots. The owning module remains responsible for the real object.
|
||||
|
||||
When Records is enabled, `records.source.cases` resolves one exact immutable
|
||||
case revision for eAkte filing. Cases rechecks current tenant, scope, and
|
||||
object-level access, then returns the case number, lifecycle state, represented
|
||||
valid interval, recorded time, canonical snapshot digest, and launch link.
|
||||
Records owns the filing decision and chronology; Cases remains authoritative
|
||||
for the case and its revision history.
|
||||
|
||||
## WebUI
|
||||
|
||||
Initial route contributions:
|
||||
|
||||
@@ -9,6 +9,7 @@ from govoplan_core.core.module_guards import (
|
||||
from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -46,6 +47,10 @@ from govoplan_cases.backend.service import (
|
||||
CAPABILITY_CASES_REGISTRY,
|
||||
SqlCaseRegistry,
|
||||
)
|
||||
from govoplan_cases.backend.record_source import (
|
||||
CAPABILITY_RECORD_SOURCE_CASES,
|
||||
create_cases_record_source,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -131,6 +136,7 @@ manifest = ModuleManifest(
|
||||
"decisions",
|
||||
"forms_runtime",
|
||||
"workflow_engine",
|
||||
"records",
|
||||
),
|
||||
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
||||
permissions=(
|
||||
@@ -283,6 +289,7 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
@@ -294,6 +301,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_CASES_PARTY_CONTEXT: _party_context,
|
||||
CAPABILITY_CASES_REGISTRY: _case_registry,
|
||||
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
||||
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
||||
@@ -316,6 +324,11 @@ manifest = ModuleManifest(
|
||||
summary="Starts a replay-safe case from an exact available Service revision.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation(
|
||||
label="Cases record source",
|
||||
summary="Resolves currently authorized immutable case revisions for Records filing.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
@@ -347,6 +360,48 @@ manifest = ModuleManifest(
|
||||
resource_acl_providers=(CaseAclProvider(),),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.file-exact-revision",
|
||||
title="File an exact case revision into an eAkte",
|
||||
summary="Preserve a reconstructable case snapshot in Records without moving case ownership.",
|
||||
body=(
|
||||
"When Records is enabled, Cases resolves an exact immutable case revision only after "
|
||||
"current case permission and object-level access are checked. The filed reference includes "
|
||||
"the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 "
|
||||
"digest of the canonical snapshot. Cases remains authoritative for the case lifecycle."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "records_manager", "operator", "auditor"),
|
||||
related_modules=("records",),
|
||||
order=15,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases", "records"),
|
||||
required_scopes=("cases:case:read", "records:workspace:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(label="Records", href="/records", kind="runtime"),
|
||||
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.detail", "records.action.file"],
|
||||
"prerequisites": [
|
||||
"You currently may read the case and file items into the destination record.",
|
||||
"The exact immutable case revision has been identified.",
|
||||
],
|
||||
"steps": [
|
||||
"Open the case and identify the revision that represents the evidence state.",
|
||||
"Choose the destination record and state the access purpose and filing reason.",
|
||||
"Confirm filing; Cases rechecks current access and resolves the exact revision.",
|
||||
"Verify the case revision and snapshot digest in the record chronology.",
|
||||
],
|
||||
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.institutional-context",
|
||||
title="Case institutional context",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordContractError,
|
||||
RecordSourceLocator,
|
||||
RecordSourceReference,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||
from govoplan_cases.backend.service import can_access_case
|
||||
|
||||
|
||||
CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases"
|
||||
|
||||
|
||||
class CasesRecordSource:
|
||||
provider_id = "cases"
|
||||
|
||||
def resource_types(self) -> Sequence[str]:
|
||||
return ("case_revision",)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
locator: RecordSourceLocator,
|
||||
purpose: str,
|
||||
) -> RecordSourceReference:
|
||||
if not isinstance(session, Session):
|
||||
raise RecordContractError(
|
||||
"Case record references require a database session."
|
||||
)
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id or locator.tenant_id != tenant_id:
|
||||
raise RecordContractError("Case record references cannot cross tenants.")
|
||||
if locator.source_module != "cases" or locator.resource_type != "case_revision":
|
||||
raise RecordContractError("Unsupported Cases record source type.")
|
||||
if not str(purpose or "").strip():
|
||||
raise RecordContractError("Case record references require a purpose.")
|
||||
if not hasattr(principal, "has") or not (
|
||||
principal.has("cases:case:read") or principal.has("cases:case:admin")
|
||||
):
|
||||
raise RecordContractError("Current Cases read permission is required.")
|
||||
if not can_access_case(
|
||||
session, principal, case_id=locator.resource_id, permission="read"
|
||||
):
|
||||
raise RecordContractError("The current principal cannot read this case.")
|
||||
try:
|
||||
revision = int(locator.source_revision)
|
||||
except ValueError as exc:
|
||||
raise RecordContractError("Case source revisions must be numeric.") from exc
|
||||
row = (
|
||||
session.query(CaseRecordRevision)
|
||||
.filter(
|
||||
CaseRecordRevision.tenant_id == tenant_id,
|
||||
CaseRecordRevision.case_id == locator.resource_id,
|
||||
CaseRecordRevision.revision == revision,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise RecordContractError("The exact case revision does not exist.")
|
||||
identity = (
|
||||
session.query(CaseIdentity)
|
||||
.filter(
|
||||
CaseIdentity.tenant_id == tenant_id,
|
||||
CaseIdentity.case_id == locator.resource_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if identity is None:
|
||||
raise RecordContractError("The case identity is unavailable.")
|
||||
snapshot_json = json.dumps(
|
||||
row.snapshot,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return RecordSourceReference(
|
||||
locator=locator,
|
||||
label=f"{identity.case_number} - {row.title}",
|
||||
authority_mode="external_authoritative",
|
||||
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
|
||||
content_type="application/vnd.govoplan.case-revision+json",
|
||||
size_bytes=len(snapshot_json),
|
||||
valid_from=row.opened_at,
|
||||
valid_to=row.closed_at,
|
||||
recorded_at=row.recorded_at,
|
||||
launch_url=f"/cases/{quote(row.case_id, safe='')}",
|
||||
metadata={
|
||||
"case_number": identity.case_number,
|
||||
"case_type_key": row.case_type_key,
|
||||
"status_key": row.status_key,
|
||||
"access_mode": row.access_mode,
|
||||
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_cases_record_source(_context: object) -> CasesRecordSource:
|
||||
return CasesRecordSource()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_RECORD_SOURCE_CASES",
|
||||
"CasesRecordSource",
|
||||
"create_cases_record_source",
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
|
||||
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||
from govoplan_cases.backend.record_source import CasesRecordSource
|
||||
|
||||
|
||||
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope == "cases:case:read"
|
||||
|
||||
|
||||
class CasesRecordSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
CaseIdentity.__table__.create(self.engine)
|
||||
CaseRecordRevision.__table__.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
identity = CaseIdentity(
|
||||
id="identity-1",
|
||||
tenant_id="tenant-1",
|
||||
case_id="case-1",
|
||||
case_number="2026/C-1",
|
||||
created_by="account-1",
|
||||
)
|
||||
revision = CaseRecordRevision(
|
||||
id="revision-1",
|
||||
tenant_id="tenant-1",
|
||||
case_id="case-1",
|
||||
identity_id="identity-1",
|
||||
revision=3,
|
||||
case_type_key="permit",
|
||||
status_key="review",
|
||||
title="Permit application",
|
||||
access_mode="restricted",
|
||||
search_text="permit application",
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
snapshot={
|
||||
"case_id": "case-1",
|
||||
"revision": 3,
|
||||
"title": "Permit application",
|
||||
},
|
||||
)
|
||||
self.session.add_all((identity, revision))
|
||||
self.session.flush()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def locator(self) -> RecordSourceLocator:
|
||||
return RecordSourceLocator(
|
||||
tenant_id="tenant-1",
|
||||
source_module="cases",
|
||||
resource_type="case_revision",
|
||||
resource_id="case-1",
|
||||
source_revision="3",
|
||||
)
|
||||
|
||||
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
||||
with patch(
|
||||
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
||||
):
|
||||
result = CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
locator=self.locator(),
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
self.assertEqual("2026/C-1 - Permit application", result.label)
|
||||
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||
self.assertEqual("review", result.metadata["status_key"])
|
||||
|
||||
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_cases.backend.record_source.can_access_case",
|
||||
return_value=False,
|
||||
),
|
||||
self.assertRaisesRegex(RecordContractError, "cannot read"),
|
||||
):
|
||||
CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
locator=self.locator(),
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user