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
|
Evidence links should store only stable module/resource references and display
|
||||||
metadata snapshots. The owning module remains responsible for the real object.
|
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
|
## WebUI
|
||||||
|
|
||||||
Initial route contributions:
|
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.institutional import CAPABILITY_PARTY_RESOLVER
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -46,6 +47,10 @@ from govoplan_cases.backend.service import (
|
|||||||
CAPABILITY_CASES_REGISTRY,
|
CAPABILITY_CASES_REGISTRY,
|
||||||
SqlCaseRegistry,
|
SqlCaseRegistry,
|
||||||
)
|
)
|
||||||
|
from govoplan_cases.backend.record_source import (
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES,
|
||||||
|
create_cases_record_source,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +136,7 @@ manifest = ModuleManifest(
|
|||||||
"decisions",
|
"decisions",
|
||||||
"forms_runtime",
|
"forms_runtime",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
|
"records",
|
||||||
),
|
),
|
||||||
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
||||||
permissions=(
|
permissions=(
|
||||||
@@ -283,6 +289,7 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
|
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
|
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="cases.service_launcher", 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=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
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_PARTY_CONTEXT: _party_context,
|
||||||
CAPABILITY_CASES_REGISTRY: _case_registry,
|
CAPABILITY_CASES_REGISTRY: _case_registry,
|
||||||
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
||||||
@@ -316,6 +324,11 @@ manifest = ModuleManifest(
|
|||||||
summary="Starts a replay-safe case from an exact available Service revision.",
|
summary="Starts a replay-safe case from an exact available Service revision.",
|
||||||
contract_version="0.1.0",
|
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(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -347,6 +360,48 @@ manifest = ModuleManifest(
|
|||||||
resource_acl_providers=(CaseAclProvider(),),
|
resource_acl_providers=(CaseAclProvider(),),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="cases.institutional-context",
|
id="cases.institutional-context",
|
||||||
title="Case 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