From f316523a1d8403bf6529a3a44a2207db0b16092f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 01:43:23 +0200 Subject: [PATCH] feat(records): implement native eAkte vertical --- AGENTS.md | 7 +- README.md | 41 +- docs/EAKTE_ARCHITECTURE.md | 22 + docs/RECORDS_DOMAIN_BOUNDARY.md | 28 +- package.json | 2 +- pyproject.toml | 2 +- src/govoplan_records/backend/db/__init__.py | 19 + src/govoplan_records/backend/db/models.py | 398 +++++ src/govoplan_records/backend/manifest.py | 451 +++++- .../backend/migrations/__init__.py | 1 + .../6e4a2c8f1d9b_v0118_records_kernel.py | 401 +++++ .../backend/migrations/versions/__init__.py | 1 + src/govoplan_records/backend/router.py | 274 ++++ src/govoplan_records/backend/schemas.py | 186 +++ src/govoplan_records/backend/search_source.py | 163 ++ src/govoplan_records/backend/service.py | 1335 +++++++++++++++++ tests/test_manifest.py | 46 +- tests/test_migrations.py | 53 + tests/test_records.py | 405 +++++ tests/test_search_source.py | 178 +++ webui/package.json | 28 + webui/src/api/records.ts | 184 +++ webui/src/features/records/RecordsPage.tsx | 802 ++++++++++ .../src/features/records/interfacePatterns.ts | 23 + webui/src/i18n/generatedTranslations.ts | 160 ++ webui/src/index.ts | 2 + webui/src/module.ts | 54 + webui/src/styles/records.css | 514 +++++++ 28 files changed, 5700 insertions(+), 80 deletions(-) create mode 100644 src/govoplan_records/backend/db/__init__.py create mode 100644 src/govoplan_records/backend/db/models.py create mode 100644 src/govoplan_records/backend/migrations/__init__.py create mode 100644 src/govoplan_records/backend/migrations/versions/6e4a2c8f1d9b_v0118_records_kernel.py create mode 100644 src/govoplan_records/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_records/backend/router.py create mode 100644 src/govoplan_records/backend/schemas.py create mode 100644 src/govoplan_records/backend/search_source.py create mode 100644 src/govoplan_records/backend/service.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_records.py create mode 100644 tests/test_search_source.py create mode 100644 webui/package.json create mode 100644 webui/src/api/records.ts create mode 100644 webui/src/features/records/RecordsPage.tsx create mode 100644 webui/src/features/records/interfacePatterns.ts create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/records.css diff --git a/AGENTS.md b/AGENTS.md index 370e287..de9a5fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ ## Scope -This repository owns the GovOPlaN Records platform module seed. +This repository owns the GovOPlaN eAkte and institutional Records domain. Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff. @@ -17,7 +17,8 @@ Records management for file plans, records classification, retention schedules, - Depend on kernel contracts from `govoplan-core` and access contracts from `govoplan-access`. - Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics. - Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior. -- Keep the seed non-invasive until runtime routes, persistence, and WebUI flows are intentionally designed. +- Preserve exact source revisions and current source authorization at every filing boundary. +- Keep lifecycle and archive effects explicit, idempotent, recoverable, and separately governed. ## Local Workflow @@ -26,5 +27,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is Focused verification: ```bash -PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests +PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests ``` diff --git a/README.md b/README.md index e01f9ff..b1593c4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,15 @@ **Repository type:** module (domain). -`govoplan-records` is the GovOPlaN platform module seed for records management for file plans, records classification, retention schedules, disposal holds, and archive handoff. +`govoplan-records` owns GovOPlaN's native eAkte boundary: versioned file plans +and record classes, stable record identities, immutable record revisions, +volumes, exact filing references, and record chronology. Source modules retain +authority over their objects and bytes. -This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation. +The first working vertical slice includes PostgreSQL/SQLite models and an +Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent +filing, temporal reads, search registration, a full-height Records workspace, +and provider-neutral filing from Files and Cases. ## Initial Ownership @@ -29,29 +35,46 @@ Detailed boundary notes are in [docs/RECORDS_DOMAIN_BOUNDARY.md](docs/RECORDS_DO ## Integrations -Expected optional integrations: +Implemented optional integrations: - files -- dms -- docs +- cases +- search + +Planned optional integrations include: + +- forms runtime and decisions +- Campaigns, Postbox, and Reporting +- DMS/archive providers - policy - audit - transparency +## Current Boundary + +The current kernel supports planned and open records. Restricted per-record +grants, closure, retention calculation, holds, appraisal, disposition, +transfer, destruction, and external archive effects remain explicit later +work packages. Enabling Records does not trigger destructive or external +effects. + +The detailed target and implementation sequence are documented in +[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md). + ## Development Install -From the core checkout: +From the workspace: ```bash -cd /mnt/DATA/git/govoplan-core -./.venv/bin/python -m pip install -e ../govoplan-records +cd /mnt/DATA/git/govoplan-records +/mnt/DATA/git/govoplan/.venv/bin/python -m pip install -e . ``` Focused manifest verification: ```bash cd /mnt/DATA/git/govoplan-records -PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests +PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests ``` ## Gitea Workflow diff --git a/docs/EAKTE_ARCHITECTURE.md b/docs/EAKTE_ARCHITECTURE.md index 6501ff3..cbd815d 100644 --- a/docs/EAKTE_ARCHITECTURE.md +++ b/docs/EAKTE_ARCHITECTURE.md @@ -15,6 +15,28 @@ specialist procedure. It must not duplicate all document editing or storage. Implementation is tracked in [Records #1](https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/1). +## Implementation Status + +The native foundation is implemented through the work packages tracked by +Records #2-#4: + +- versioned file plans and record classes; +- stable records, immutable revisions, volumes, exact record items, and + chronology; +- tenant isolation, optimistic concurrency, replay-safe writes, independent + valid/recorded time, purpose capture, institutional context, and search; +- a full-height eAkte workspace with file plan, list, details, chronology, + temporal status, create/edit, and filing actions; +- a provider-neutral Core filing contract with exact Files-version and + Cases-revision providers. + +The remaining delivery order is intentionally visible rather than implied: +Records #5 owns closure, retention, holds, appraisal, and disposition; #6 owns +recovery and signed evidence; #7 requires selection and target testing of an +archive/xdomea provider; and #8 proves the reference journey. Restricted +per-record access grants also remain a dedicated access-policy slice. No +archive or destructive effect is currently claimed. + ## Ownership Boundary Records owns: diff --git a/docs/RECORDS_DOMAIN_BOUNDARY.md b/docs/RECORDS_DOMAIN_BOUNDARY.md index 70b1475..240d78d 100644 --- a/docs/RECORDS_DOMAIN_BOUNDARY.md +++ b/docs/RECORDS_DOMAIN_BOUNDARY.md @@ -28,22 +28,30 @@ Records management for file plans, records classification, retention schedules, - audit - transparency -## Seed State +## Implemented State -The current repository state is intentionally small: +The native kernel currently provides: -- module manifest and entry point -- tenant-level permission definitions -- manager and viewer role templates -- documentation topic describing the module boundary -- Gitea issue workflow templates -- manifest contract test +- versioned file-plan nodes and record classes; +- stable record identities, immutable OCC-guarded revisions, volumes, exact + filed items, and chronology; +- independent valid and recorded time with current/at/all temporal reads; +- tenant, purpose, actor/capacity, source authority, institutional context, + provenance, and idempotency fields; +- provider-neutral exact-source capabilities, initially implemented by Files + file versions and Cases revisions; +- tenant APIs, search projection, uninstall/retirement guards, and a Records + workspace using shared WebUI controls. -No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. +Restricted object grants and the lifecycle after the planned/open stages remain +separate governed slices. Archive transfer and destructive effects are not +implemented by this kernel. ## First Implementation Slice -Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links. +Complete restricted access, closure and retention calculation, holds, +appraisal, disposition, transfer, recovery evidence, and one target-tested +archive provider without moving source-module ownership into Records. The complete native/external boundary, temporal and purpose-aware record model, disposition lifecycle, German public-sector provider profiles, and staged diff --git a/package.json b/package.json index a4c7790..845f5b7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@govoplan/records", "version": "0.1.18", "private": true, - "description": "GovOPlaN Records platform module seed.", + "description": "GovOPlaN eAkte and institutional records module.", "type": "module", "peerDependencies": {} } diff --git a/pyproject.toml b/pyproject.toml index f3bd971..37c6b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-records" version = "0.1.18" -description = "GovOPlaN Records platform module seed." +description = "GovOPlaN eAkte and institutional records module." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } diff --git a/src/govoplan_records/backend/db/__init__.py b/src/govoplan_records/backend/db/__init__.py new file mode 100644 index 0000000..963deb6 --- /dev/null +++ b/src/govoplan_records/backend/db/__init__.py @@ -0,0 +1,19 @@ +from govoplan_records.backend.db.models import ( + RecordChronologyEntry, + RecordClassRevision, + RecordFilePlanRevision, + RecordIdentity, + RecordItem, + RecordRevision, + RecordVolumeRevision, +) + +__all__ = [ + "RecordChronologyEntry", + "RecordClassRevision", + "RecordFilePlanRevision", + "RecordIdentity", + "RecordItem", + "RecordRevision", + "RecordVolumeRevision", +] diff --git a/src/govoplan_records/backend/db/models.py b/src/govoplan_records/backend/db/models.py new file mode 100644 index 0000000..aad4f9e --- /dev/null +++ b/src/govoplan_records/backend/db/models.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import ( + BigInteger, + 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 RecordFilePlanRevision(Base, TimestampMixin): + __tablename__ = "record_file_plan_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "node_id", "revision", name="uq_record_file_plan_revision" + ), + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency" + ), + Index("ix_record_file_plan_current", "tenant_id", "node_id", "superseded_at"), + Index("ix_record_file_plan_tree", "tenant_id", "parent_node_id", "code"), + ) + + 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) + node_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("record_file_plan_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + parent_node_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + code: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + label: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + active: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, index=True + ) + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + valid_to: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=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 + ) + institutional_context: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + changed_by: 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) + + +class RecordClassRevision(Base, TimestampMixin): + __tablename__ = "record_class_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "class_id", "revision", name="uq_record_class_revision" + ), + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_class_idempotency" + ), + Index("ix_record_class_current", "tenant_id", "class_id", "superseded_at"), + Index( + "ix_record_class_catalog", + "tenant_id", + "file_plan_node_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) + class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("record_class_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + file_plan_node_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + key: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + label: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_requirements: Mapped[list[str]] = mapped_column( + JSON, default=list, nullable=False + ) + allowed_source_types: Mapped[list[str]] = mapped_column( + JSON, default=list, nullable=False + ) + retention_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + closure_trigger: Mapped[str | None] = mapped_column(String(255), nullable=True) + access_mode: Mapped[str] = mapped_column( + String(30), default="tenant", nullable=False + ) + active: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, index=True + ) + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + valid_to: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=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 + ) + institutional_context: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + changed_by: 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) + + +class RecordIdentity(Base, TimestampMixin): + __tablename__ = "record_identities" + __table_args__ = ( + UniqueConstraint("tenant_id", "record_id", name="uq_record_identity_tenant_id"), + UniqueConstraint( + "tenant_id", "record_number", name="uq_record_identity_tenant_number" + ), + Index("ix_record_identity_catalog", "tenant_id", "record_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) + record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + record_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 RecordRevision(Base, TimestampMixin): + __tablename__ = "record_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "record_id", "revision", name="uq_record_revision" + ), + Index("ix_record_current", "tenant_id", "record_id", "superseded_at"), + Index( + "ix_record_catalog", "tenant_id", "state", "class_id", "file_plan_node_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) + record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + identity_id: Mapped[str] = mapped_column( + ForeignKey("record_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("record_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + file_plan_node_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + state: Mapped[str] = mapped_column( + String(40), default="open", nullable=False, index=True + ) + source_authority_mode: Mapped[str] = mapped_column( + String(40), default="native_authoritative", nullable=False + ) + access_mode: Mapped[str] = mapped_column( + String(30), default="tenant", nullable=False + ) + purpose: Mapped[str] = mapped_column(String(255), nullable=False) + classification: Mapped[str | None] = mapped_column( + String(120), nullable=True, index=True + ) + responsible_unit_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + responsible_function_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + external_reference: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + institutional_context: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + valid_to: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=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 + ) + changed_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class RecordVolumeRevision(Base, TimestampMixin): + __tablename__ = "record_volume_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "volume_id", "revision", name="uq_record_volume_revision" + ), + Index("ix_record_volume_current", "tenant_id", "volume_id", "superseded_at"), + Index("ix_record_volume_order", "tenant_id", "record_id", "sequence"), + ) + + 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) + volume_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("record_volume_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + label: Mapped[str] = mapped_column(String(500), nullable=False) + state: Mapped[str] = mapped_column( + String(40), default="open", nullable=False, index=True + ) + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + valid_to: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=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 + ) + changed_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + + +class RecordItem(Base, TimestampMixin): + __tablename__ = "record_items" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_item_idempotency" + ), + UniqueConstraint( + "tenant_id", "record_id", "sequence", name="uq_record_item_sequence" + ), + Index( + "ix_record_item_source", + "tenant_id", + "source_module", + "resource_type", + "resource_id", + ), + Index("ix_record_item_record", "tenant_id", "record_id", "sequence"), + ) + + 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) + record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + volume_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + source_module: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + resource_id: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + source_revision: Mapped[str] = mapped_column(String(255), nullable=False) + label: Mapped[str] = mapped_column(String(500), nullable=False) + relationship: Mapped[str] = mapped_column(String(120), nullable=False) + filing_reason: Mapped[str] = mapped_column(Text, nullable=False) + purpose: Mapped[str] = mapped_column(String(255), nullable=False) + authority_mode: Mapped[str] = mapped_column(String(40), nullable=False) + content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + content_type: Mapped[str | None] = mapped_column(String(255), nullable=True) + size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + source_valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + source_valid_to: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + source_recorded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + launch_url: Mapped[str | None] = mapped_column(String(1500), nullable=True) + filed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + filed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + institutional_context: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + source_metadata: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + filing_metadata: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + supersedes_item_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + + +class RecordChronologyEntry(Base, TimestampMixin): + __tablename__ = "record_chronology_entries" + __table_args__ = ( + UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"), + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_chronology_idempotency" + ), + Index("ix_record_chronology_record", "tenant_id", "record_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) + record_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) + record_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) + actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + purpose: Mapped[str] = mapped_column(String(255), nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + institutional_context: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +__all__ = [ + "RecordChronologyEntry", + "RecordClassRevision", + "RecordFilePlanRevision", + "RecordIdentity", + "RecordItem", + "RecordRevision", + "RecordVolumeRevision", +] diff --git a/src/govoplan_records/backend/manifest.py b/src/govoplan_records/backend/manifest.py index d4edaef..dd65f18 100644 --- a/src/govoplan_records/backend/manifest.py +++ b/src/govoplan_records/backend/manifest.py @@ -1,8 +1,42 @@ from __future__ import annotations -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate +from pathlib import Path + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.information_governance import ( + InformationGovernanceDimension, + ModuleInformationGovernance, +) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationLink, + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.records import CAPABILITY_RECORDS_FILING +from govoplan_core.core.search import SearchSourceProviderRegistration +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_records.backend.db import models as record_models +from govoplan_records.backend.search_source import create_records_search_source +from govoplan_records.backend.service import SqlRecordRegistry + MODULE_ID = "records" MODULE_NAME = "Records" @@ -12,11 +46,18 @@ WRITE_SCOPE = "records:workspace:write" ADMIN_SCOPE = "records:workspace:admin" OPTIONAL_DEPENDENCIES = ( "files", + "cases", + "forms_runtime", + "decisions", + "campaigns", + "postbox", + "reporting", "dms", "docs", "policy", "audit", "transparency", + "search", ) @@ -34,108 +75,430 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio ) +def _router(context: ModuleContext): + from govoplan_records.backend.router import create_router + + return create_router(context.registry) + + +def _records_registry(context: ModuleContext) -> SqlRecordRegistry: + return SqlRecordRegistry(context.registry) + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + records = ( + session.query(record_models.RecordIdentity) + .filter(record_models.RecordIdentity.tenant_id == tenant_id) + .count() + ) + open_records = ( + session.query(record_models.RecordRevision) + .filter( + record_models.RecordRevision.tenant_id == tenant_id, + record_models.RecordRevision.superseded_at.is_(None), + record_models.RecordRevision.state == "open", + ) + .count() + ) + return {"records": records, "open_records": open_records} + + PERMISSIONS = ( - _permission(READ_SCOPE, "View records workspace", "Read records, configuration, and workflow context."), - _permission(WRITE_SCOPE, "Manage records workspace", "Create and update records and workflow state."), - _permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."), + _permission( + READ_SCOPE, + "View records workspace", + "Read currently authorized records, contents, chronology, and file-plan context.", + ), + _permission( + WRITE_SCOPE, + "Manage records workspace", + "Create and revise records, create volumes, and file exact source revisions.", + ), + _permission( + ADMIN_SCOPE, + "Administer records workspace", + "Version file-plan nodes and record classes and administer Records configuration.", + ), ) ROLE_TEMPLATES = ( RoleTemplate( slug="records_manager", name="Records manager", - description="Manage records and workflow state.", + description="Create, revise, structure, and file content into records.", permissions=(READ_SCOPE, WRITE_SCOPE), ), RoleTemplate( slug="records_viewer", name="Records viewer", - description="Read records and workflow context.", + description="Read records and their governed chronology.", permissions=(READ_SCOPE,), ), + RoleTemplate( + slug="records_administrator", + name="Records administrator", + description="Configure file plans and record classes and manage records.", + permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE), + ), ) DOCUMENTATION = ( DocumentationTopic( - id=f"{MODULE_ID}.module-boundary", - title=f"{MODULE_NAME} module boundary", - summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.", + id="records.workspace", + title="eAkte workspace", + summary="Create and browse institutional records, their exact filed items, and chronology.", body=( - "This repository is currently a platform module seed. It registers the domain boundary, " - "permission surface, role templates, and documentation metadata before runtime APIs, " - "database models, migrations, and WebUI routes are introduced." + "Records owns the stable record identity, file-plan classification, immutable revisions, " + "volumes, filing decisions, and chronology. Files and other source modules continue to own " + "their content. Filing resolves and preserves an exact source revision only after the source " + "module confirms current access. The titlebar temporal selection changes valid and recorded " + "time while current authorization always remains in force." ), - layer="available", + layer="configured", documentation_types=("admin", "user"), - audience=("user", "operator", "module_admin", "product_owner"), + audience=("user", "records_manager", "operator", "module_admin", "auditor"), order=100, related_modules=OPTIONAL_DEPENDENCIES, links=( - DocumentationLink( - label="Repository domain boundary", - href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md", - kind="repository", - ), DocumentationLink( label="eAkte architecture", href="govoplan-records/docs/EAKTE_ARCHITECTURE.md", kind="repository", ), ), + translations={ + "de": { + "title": "eAkte-Arbeitsbereich", + "summary": "Institutionelle Akten, exakt veraktete Objekte und die Chronologie anlegen und einsehen.", + "body": ( + "Records verwaltet die stabile Aktenidentität, Aktenplanklassifikation, unveränderliche " + "Revisionen, Bände, Veraktungsentscheidungen und die Chronologie. Dateien und andere " + "Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller " + "Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale " + "Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung " + "gilt stets weiter." + ), + } + }, metadata={ - "seed": True, - "domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'], - "first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.", + "help_contexts": [ + "records.workspace", + "records.file-plan", + "records.record-list", + "records.record-detail", + "records.record-items", + "records.chronology", + "records.action.create", + "records.action.edit", + "records.field.record-number", + "records.field.state", + "records.field.title", + "records.field.class", + "records.field.classification", + "records.field.description", + "records.field.change-reason", + ], }, ), DocumentationTopic( - id=f"{MODULE_ID}.eakte-architecture", - title="eAkte and digital record lifecycle", - summary="Defines native and external record operation, filing, temporal and purpose-aware access, retention, holds, appraisal, transfer, and disposition.", + id="records.filing", + title="Exact record filing", + summary="File immutable Files or Cases revisions through a provider-neutral capability.", body=( - "Records owns the legal and institutional record identity, file plan, filing decisions, " - "retention and disposition lifecycle, and transfer evidence. Files owns bytes, DMS owns " - "document editing, Policy owns reusable rules, and external archives remain supported " - "through explicit source-authority and provider profiles." + "Every filing requires a record, purpose, filing reason, idempotency key, and exact source " + "revision. Records stores source identity, authority mode, digest and content metadata where " + "available, represented valid time, source recorded time, filing actor and capacity, and an " + "immutable chronology entry. A repeated idempotency key replays only the identical request." ), - layer="available", + layer="configured", documentation_types=("admin", "user"), - audience=("user", "records_manager", "operator", "module_admin", "product_owner"), + audience=("user", "records_manager", "operator", "module_admin", "auditor"), order=110, - related_modules=OPTIONAL_DEPENDENCIES, + related_modules=("files", "cases", "policy", "audit"), links=( DocumentationLink( - label="eAkte architecture", - href="govoplan-records/docs/EAKTE_ARCHITECTURE.md", + label="Records domain boundary", + href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md", kind="repository", ), ), + translations={ + "de": { + "title": "Exakte Veraktung", + "summary": "Unveränderliche Datei- oder Vorgangsrevisionen über eine anbieterneutrale Schnittstelle verakten.", + "body": ( + "Jede Veraktung benötigt eine Akte, einen Zweck, eine Veraktungsbegründung, einen " + "Idempotenzschlüssel und eine exakte Quellrevision. Records speichert Quellidentität, " + "Autoritätsmodus, soweit verfügbar Prüfsumme und Inhaltsmetadaten, Gültigkeits- und " + "Erfassungszeit der Quelle, handelnde Person und Funktion sowie einen unveränderlichen " + "Chronologieeintrag. Ein wiederholter Idempotenzschlüssel gibt nur dieselbe Anfrage erneut aus." + ), + } + }, metadata={ - "kind": "concept", - "help_contexts": ["records.page", "records.record", "records.disposition"], - "known_limit": "The architecture is accepted, but persistence and user-visible record workflows remain a scaffold.", + "help_contexts": [ + "records.action.file", + "records.field.source-module", + "records.field.source-object", + "records.field.source-revision", + "records.field.purpose", + "records.field.filing-reason", + ], }, ), + DocumentationTopic( + id="records.lifecycle-limitations", + title="Records lifecycle limitations", + summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.", + body=( + "The current vertical supports planned and open records. Restricted object grants, closure, " + "retention calculation, holds, appraisal, disposition, transfer, destruction, and external " + "archive effects are separate governed work packages. No destructive effect is implied by " + "enabling Records." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "records_manager", "operator", "module_admin", "auditor"), + order=120, + related_modules=("policy", "approvals", "audit", "dms"), + translations={ + "de": { + "title": "Grenzen des Aktenlebenszyklus", + "summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.", + "body": ( + "Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, " + "Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung " + "und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von " + "Records löst keine vernichtende Wirkung aus." + ), + } + }, + metadata={"known_limit": True}, + ), ) + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, version=MODULE_VERSION, dependencies=("access",), optional_dependencies=OPTIONAL_DEPENDENCIES, - required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, + route_factory=_router, + nav_items=( + NavItem( + path="/records", + label="Records", + icon="archive", + required_any=(READ_SCOPE,), + order=47, + surface_id="records.navigation", + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/records-webui", + routes=( + FrontendRoute( + path="/records", + component="RecordsPage", + required_any=(READ_SCOPE,), + order=47, + surface_id="records.workspace", + ), + ), + nav_items=( + NavItem( + path="/records", + label="Records", + icon="archive", + required_any=(READ_SCOPE,), + order=47, + surface_id="records.navigation", + ), + ), + view_surfaces=( + ViewSurface( + id="records.workspace.file-plan", + module_id=MODULE_ID, + kind="section", + label="File plan", + parent_id="records.workspace", + order=10, + ), + ViewSurface( + id="records.workspace.list", + module_id=MODULE_ID, + kind="section", + label="Record list", + parent_id="records.workspace", + order=20, + ), + ViewSurface( + id="records.workspace.detail", + module_id=MODULE_ID, + kind="section", + label="Record detail", + parent_id="records.workspace", + order=30, + ), + ViewSurface( + id="records.workspace.file", + module_id=MODULE_ID, + kind="action", + label="File source revision", + parent_id="records.workspace.detail", + order=40, + ), + ), + ), + provides_interfaces=( + ModuleInterfaceProvider(name="records.registry", version="1.0.0"), + ModuleInterfaceProvider(name="records.filing", version="1.0.0"), + ), + capability_factories={CAPABILITY_RECORDS_FILING: _records_registry}, + capability_documentation={ + CAPABILITY_RECORDS_FILING: CapabilityDocumentation( + label="Record filing", + summary="Resolves authorized exact source revisions and files immutable record items.", + contract_version="1.0.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( + record_models.RecordChronologyEntry, + record_models.RecordItem, + record_models.RecordVolumeRevision, + record_models.RecordRevision, + record_models.RecordIdentity, + record_models.RecordClassRevision, + record_models.RecordFilePlanRevision, + label="Records", + ), + retirement_notes=( + "Destructive retirement requires a database snapshot and removes record identities, " + "file plans, exact filing references, and chronology. Source content remains provider-owned." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + record_models.RecordIdentity, + record_models.RecordRevision, + record_models.RecordItem, + record_models.RecordChronologyEntry, + record_models.RecordClassRevision, + record_models.RecordFilePlanRevision, + label="Records", + ), + ), + tenant_summary_providers=(_tenant_summary,), + search_sources=( + SearchSourceProviderRegistration( + id="records.objects", factory=create_records_search_source + ), + ), documentation=DOCUMENTATION, + information_governance=ModuleInformationGovernance( + temporal_browsing=InformationGovernanceDimension( + adoption="enforced", + object_types=( + "record", + "record_volume", + "record_item", + "record_class", + "file_plan_node", + ), + evidence=( + "src/govoplan_records/backend/service.py", + "tests/test_records.py", + ), + ), + purpose_aware_access=InformationGovernanceDimension( + adoption="partial", + object_types=("record", "record_item"), + evidence=("src/govoplan_records/backend/service.py",), + limitation=( + "Purposes are mandatory and preserved for record operations, but Policy-backed " + "object-level purpose constraints and restricted-record grants are not implemented yet." + ), + ), + retention=InformationGovernanceDimension( + adoption="contract_only", + limitation=( + "Record classes preserve retention inputs; closure, holds, calculation, appraisal, " + "and disposition are tracked in Records #5." + ), + ), + institutional_context=InformationGovernanceDimension( + adoption="enforced", + object_types=( + "record", + "record_item", + "record_event", + "record_class", + "file_plan_node", + ), + evidence=( + "src/govoplan_records/backend/db/models.py", + "src/govoplan_records/backend/service.py", + "tests/test_records.py", + ), + ), + ), architecture=declared_module_architecture( layer="content_records_evidence", kind="domain", - maturity="scaffold", - documentation_ref="docs/RECORDS_DOMAIN_BOUNDARY.md", - known_limits=("Record declaration, retention, hold, transfer, and disposal are not implemented yet.",), - owned_concepts=("record", "record classification", "disposition"), - non_owned_concepts=("file content", "audit event", "domain object"), + maturity="vertical_slice", + documentation_ref="docs/EAKTE_ARCHITECTURE.md", + test_ref="tests/test_records.py", + known_limits=( + "Restricted object grants and lifecycle stages after open are tracked separately.", + "Archive transfer and destructive effects are not part of the native kernel.", + ), + supported_authority_modes=( + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + "governance_overlay", + "linked_reference", + ), + owned_concepts=( + "record", + "record class", + "file plan", + "record volume", + "record item", + "filing decision", + "record chronology", + ), + non_owned_concepts=( + "file content", + "source object", + "case lifecycle", + "workflow execution", + "generic policy", + "audit event", + "archive preservation provider", + ), + reference_packages=( + "product.service-to-decision", + "product.monthly-data-operations", + ), + migration_docs=("docs/EAKTE_ARCHITECTURE.md",), + recovery_docs=("docs/EAKTE_ARCHITECTURE.md",), + security_docs=("docs/EAKTE_ARCHITECTURE.md",), + operations_docs=("docs/EAKTE_ARCHITECTURE.md",), ), ) diff --git a/src/govoplan_records/backend/migrations/__init__.py b/src/govoplan_records/backend/migrations/__init__.py new file mode 100644 index 0000000..f813dad --- /dev/null +++ b/src/govoplan_records/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Records Alembic migrations.""" diff --git a/src/govoplan_records/backend/migrations/versions/6e4a2c8f1d9b_v0118_records_kernel.py b/src/govoplan_records/backend/migrations/versions/6e4a2c8f1d9b_v0118_records_kernel.py new file mode 100644 index 0000000..be9ea29 --- /dev/null +++ b/src/govoplan_records/backend/migrations/versions/6e4a2c8f1d9b_v0118_records_kernel.py @@ -0,0 +1,401 @@ +"""v0.1.18 Records kernel. + +Revision ID: 6e4a2c8f1d9b +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "6e4a2c8f1d9b" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "record_file_plan_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("node_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("parent_node_id", sa.String(length=255), nullable=True), + sa.Column("code", sa.String(length=120), nullable=False), + sa.Column("label", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", 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("institutional_context", sa.JSON(), nullable=False), + sa.Column("changed_by", 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("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["previous_revision_id"], + ["record_file_plan_revisions.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency" + ), + sa.UniqueConstraint( + "tenant_id", "node_id", "revision", name="uq_record_file_plan_revision" + ), + ) + _indexes( + "record_file_plan_revisions", + "tenant_id", + "node_id", + "previous_revision_id", + "parent_node_id", + "code", + "active", + "recorded_at", + "superseded_at", + "changed_by", + ) + op.create_index( + "ix_record_file_plan_current", + "record_file_plan_revisions", + ["tenant_id", "node_id", "superseded_at"], + ) + op.create_index( + "ix_record_file_plan_tree", + "record_file_plan_revisions", + ["tenant_id", "parent_node_id", "code"], + ) + + op.create_table( + "record_class_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("class_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("file_plan_node_id", sa.String(length=255), nullable=False), + sa.Column("key", sa.String(length=120), nullable=False), + sa.Column("label", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("metadata_requirements", sa.JSON(), nullable=False), + sa.Column("allowed_source_types", sa.JSON(), nullable=False), + sa.Column("retention_period_days", sa.Integer(), nullable=True), + sa.Column("closure_trigger", sa.String(length=255), nullable=True), + sa.Column("access_mode", sa.String(length=30), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", 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("institutional_context", sa.JSON(), nullable=False), + sa.Column("changed_by", 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("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["previous_revision_id"], ["record_class_revisions.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", "class_id", "revision", name="uq_record_class_revision" + ), + sa.UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_class_idempotency" + ), + ) + _indexes( + "record_class_revisions", + "tenant_id", + "class_id", + "previous_revision_id", + "file_plan_node_id", + "key", + "active", + "recorded_at", + "superseded_at", + "changed_by", + ) + op.create_index( + "ix_record_class_current", + "record_class_revisions", + ["tenant_id", "class_id", "superseded_at"], + ) + op.create_index( + "ix_record_class_catalog", + "record_class_revisions", + ["tenant_id", "file_plan_node_id", "active", "label"], + ) + + op.create_table( + "record_identities", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("record_id", sa.String(length=255), nullable=False), + sa.Column("record_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"), + sa.UniqueConstraint( + "tenant_id", "record_id", name="uq_record_identity_tenant_id" + ), + sa.UniqueConstraint( + "tenant_id", "record_number", name="uq_record_identity_tenant_number" + ), + ) + _indexes( + "record_identities", "tenant_id", "record_id", "record_number", "created_by" + ) + op.create_index( + "ix_record_identity_catalog", + "record_identities", + ["tenant_id", "record_number"], + ) + + op.create_table( + "record_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("record_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("class_id", sa.String(length=255), nullable=False), + sa.Column("file_plan_node_id", sa.String(length=255), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("state", sa.String(length=40), nullable=False), + sa.Column("source_authority_mode", sa.String(length=40), nullable=False), + sa.Column("access_mode", sa.String(length=30), nullable=False), + sa.Column("purpose", sa.String(length=255), nullable=False), + sa.Column("classification", sa.String(length=120), nullable=True), + sa.Column("responsible_unit_id", sa.String(length=255), nullable=True), + sa.Column("responsible_function_id", sa.String(length=255), nullable=True), + sa.Column("external_reference", sa.JSON(), nullable=False), + sa.Column("institutional_context", sa.JSON(), nullable=False), + sa.Column("search_text", sa.Text(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", 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("changed_by", sa.String(length=255), nullable=True), + sa.Column("snapshot", 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.ForeignKeyConstraint( + ["identity_id"], ["record_identities.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["previous_revision_id"], ["record_revisions.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", "record_id", "revision", name="uq_record_revision" + ), + ) + _indexes( + "record_revisions", + "tenant_id", + "record_id", + "identity_id", + "previous_revision_id", + "class_id", + "file_plan_node_id", + "state", + "classification", + "responsible_unit_id", + "responsible_function_id", + "recorded_at", + "superseded_at", + "changed_by", + ) + op.create_index( + "ix_record_current", + "record_revisions", + ["tenant_id", "record_id", "superseded_at"], + ) + op.create_index( + "ix_record_catalog", + "record_revisions", + ["tenant_id", "state", "class_id", "file_plan_node_id"], + ) + + op.create_table( + "record_volume_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("volume_id", sa.String(length=255), nullable=False), + sa.Column("record_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("label", sa.String(length=500), nullable=False), + sa.Column("state", sa.String(length=40), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", 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("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( + ["previous_revision_id"], + ["record_volume_revisions.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", "volume_id", "revision", name="uq_record_volume_revision" + ), + ) + _indexes( + "record_volume_revisions", + "tenant_id", + "volume_id", + "record_id", + "previous_revision_id", + "state", + "recorded_at", + "superseded_at", + "changed_by", + ) + op.create_index( + "ix_record_volume_current", + "record_volume_revisions", + ["tenant_id", "volume_id", "superseded_at"], + ) + op.create_index( + "ix_record_volume_order", + "record_volume_revisions", + ["tenant_id", "record_id", "sequence"], + ) + + op.create_table( + "record_items", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("record_id", sa.String(length=255), nullable=False), + sa.Column("volume_id", sa.String(length=255), nullable=True), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("source_module", sa.String(length=100), nullable=False), + sa.Column("resource_type", sa.String(length=120), nullable=False), + sa.Column("resource_id", sa.String(length=500), nullable=False), + sa.Column("source_revision", sa.String(length=255), nullable=False), + sa.Column("label", sa.String(length=500), nullable=False), + sa.Column("relationship", sa.String(length=120), nullable=False), + sa.Column("filing_reason", sa.Text(), nullable=False), + sa.Column("purpose", sa.String(length=255), nullable=False), + sa.Column("authority_mode", sa.String(length=40), nullable=False), + sa.Column("content_sha256", sa.String(length=64), nullable=True), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=True), + sa.Column("source_valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_recorded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("launch_url", sa.String(length=1500), nullable=True), + sa.Column("filed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("filed_by", sa.String(length=255), nullable=True), + sa.Column("actor_assignment_id", sa.String(length=255), nullable=True), + sa.Column("actor_delegation_id", sa.String(length=255), nullable=True), + sa.Column("institutional_context", sa.JSON(), nullable=False), + sa.Column("source_metadata", sa.JSON(), nullable=False), + sa.Column("filing_metadata", sa.JSON(), nullable=False), + sa.Column("supersedes_item_id", sa.String(length=36), nullable=True), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), 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"), + sa.UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_item_idempotency" + ), + sa.UniqueConstraint( + "tenant_id", "record_id", "sequence", name="uq_record_item_sequence" + ), + ) + _indexes( + "record_items", + "tenant_id", + "record_id", + "volume_id", + "source_module", + "resource_type", + "resource_id", + "filed_at", + "filed_by", + "supersedes_item_id", + ) + op.create_index( + "ix_record_item_source", + "record_items", + ["tenant_id", "source_module", "resource_type", "resource_id"], + ) + op.create_index( + "ix_record_item_record", "record_items", ["tenant_id", "record_id", "sequence"] + ) + + op.create_table( + "record_chronology_entries", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("record_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("record_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("actor_assignment_id", sa.String(length=255), nullable=True), + sa.Column("actor_delegation_id", sa.String(length=255), nullable=True), + sa.Column("purpose", sa.String(length=255), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("institutional_context", sa.JSON(), nullable=False), + 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"), + sa.UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"), + sa.UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_record_chronology_idempotency" + ), + ) + _indexes( + "record_chronology_entries", + "tenant_id", + "record_id", + "event_id", + "event_type", + "occurred_at", + "actor_id", + ) + op.create_index( + "ix_record_chronology_record", + "record_chronology_entries", + ["tenant_id", "record_id", "occurred_at"], + ) + + +def downgrade() -> None: + op.drop_table("record_chronology_entries") + op.drop_table("record_items") + op.drop_table("record_volume_revisions") + op.drop_table("record_revisions") + op.drop_table("record_identities") + op.drop_table("record_class_revisions") + op.drop_table("record_file_plan_revisions") + + +def _indexes(table: str, *columns: str) -> None: + for column in columns: + op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False) diff --git a/src/govoplan_records/backend/migrations/versions/__init__.py b/src/govoplan_records/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..715dfc4 --- /dev/null +++ b/src/govoplan_records/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Records migration revisions.""" diff --git a/src/govoplan_records/backend/router.py b/src/govoplan_records/backend/router.py new file mode 100644 index 0000000..1748d89 --- /dev/null +++ b/src/govoplan_records/backend/router.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator +from govoplan_core.db.session import get_session +from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE +from govoplan_records.backend.schemas import ( + FilePlanNodeWriteRequest, + RecordCatalogResponse, + RecordClassWriteRequest, + RecordCreateRequest, + RecordDetailResponse, + RecordItemCreateRequest, + RecordListResponse, + RecordSourceProviderResponse, + RecordUpdateRequest, + RecordVolumeCreateRequest, +) +from govoplan_records.backend.service import ( + RecordConflictError, + RecordNotFoundError, + RecordSourceUnavailableError, + RecordStoreError, + SqlRecordRegistry, +) + + +def create_router(registry: object | None = None) -> APIRouter: + router = APIRouter(prefix="/records", tags=["records"]) + records = SqlRecordRegistry(registry) + + @router.get("/catalog", response_model=RecordCatalogResponse) + def api_catalog( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> RecordCatalogResponse: + _require(principal, READ_SCOPE) + return RecordCatalogResponse(**records.catalog(session, principal)) + + @router.post( + "/catalog/file-plan", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, + ) + def api_write_file_plan_node( + payload: FilePlanNodeWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, ADMIN_SCOPE) + return _write( + session, + lambda: records.write_file_plan_node( + session, principal, payload=payload.model_dump(mode="python") + ), + ) + + @router.post( + "/catalog/classes", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, + ) + def api_write_record_class( + payload: RecordClassWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, ADMIN_SCOPE) + return _write( + session, + lambda: records.write_record_class( + session, principal, payload=payload.model_dump(mode="python") + ), + ) + + @router.get("/sources", response_model=RecordSourceProviderResponse) + def api_source_providers( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> RecordSourceProviderResponse: + _require(principal, WRITE_SCOPE) + return RecordSourceProviderResponse( + providers=records.source_providers(session, principal) + ) + + @router.get("", response_model=RecordListResponse) + def api_list_records( + query: str | None = Query(default=None, max_length=500), + record_state: str | None = Query(default=None, alias="state", max_length=40), + class_id: str | None = Query(default=None, max_length=255), + file_plan_node_id: str | None = Query(default=None, max_length=255), + 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), + ) -> RecordListResponse: + _require(principal, READ_SCOPE) + items, total = records.list_records( + session, + principal, + query=query, + state=record_state, + class_id=class_id, + file_plan_node_id=file_plan_node_id, + offset=offset, + limit=limit, + ) + return RecordListResponse( + records=items, total=total, offset=offset, limit=limit + ) + + @router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) + def api_create_record( + payload: RecordCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + return _write( + session, + lambda: records.create_record( + session, principal, payload=payload.model_dump(mode="python") + ), + ) + + @router.get("/{record_id}", response_model=RecordDetailResponse) + def api_get_record( + record_id: str, + revision: int | None = Query(default=None, ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> RecordDetailResponse: + _require(principal, READ_SCOPE) + try: + return RecordDetailResponse( + **records.get_record( + session, principal, record_id=record_id, revision=revision + ) + ) + except RecordStoreError as exc: + raise _http_error(exc) from exc + + @router.patch("/{record_id}", response_model=dict[str, Any]) + def api_update_record( + record_id: str, + payload: RecordUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + return _write( + session, + lambda: records.update_record( + session, + principal, + record_id=record_id, + payload=payload.model_dump(mode="python", exclude_unset=True), + ), + ) + + @router.post( + "/{record_id}/volumes", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, + ) + def api_create_volume( + record_id: str, + payload: RecordVolumeCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + return _write( + session, + lambda: records.create_volume( + session, + principal, + record_id=record_id, + payload=payload.model_dump(mode="python"), + ), + ) + + @router.post( + "/{record_id}/items", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, + ) + def api_file_item( + record_id: str, + payload: RecordItemCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + request = RecordFilingRequest( + tenant_id=principal.tenant_id, + record_id=record_id, + source=RecordSourceLocator( + tenant_id=principal.tenant_id, + source_module=payload.source.source_module, + resource_type=payload.source.resource_type, + resource_id=payload.source.resource_id, + source_revision=payload.source.source_revision, + metadata=payload.source.metadata, + ), + purpose=payload.purpose, + filing_reason=payload.filing_reason, + idempotency_key=payload.idempotency_key, + volume_id=payload.volume_id, + relationship=payload.relationship, + institutional_context=payload.institutional_context, + metadata=payload.metadata, + ) + + def operation() -> dict[str, Any]: + result = records.file(session, principal, request=request) + return { + "record_id": result.record_id, + "item_id": result.item_id, + "sequence": result.sequence, + "filed_at": result.filed_at, + "replayed": result.replayed, + "source": { + "source_module": result.source.locator.source_module, + "resource_type": result.source.locator.resource_type, + "resource_id": result.source.locator.resource_id, + "source_revision": result.source.locator.source_revision, + "label": result.source.label, + }, + } + + return _write(session, operation) + + return router + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _write(session: Session, operation): + try: + result = operation() + session.commit() + return result + except (RecordStoreError, IntegrityError) as exc: + session.rollback() + if isinstance(exc, IntegrityError): + raise HTTPException( + status_code=409, detail="The record write conflicts with existing data." + ) from exc + raise _http_error(exc) from exc + + +def _http_error(exc: RecordStoreError) -> HTTPException: + if isinstance(exc, RecordNotFoundError): + code = 404 + elif isinstance(exc, RecordConflictError): + code = 409 + elif isinstance(exc, RecordSourceUnavailableError): + code = 503 + else: + code = 422 + return HTTPException(status_code=code, detail=str(exc)) + + +__all__ = ["create_router"] diff --git a/src/govoplan_records/backend/schemas.py b/src/govoplan_records/backend/schemas.py new file mode 100644 index 0000000..95e36a7 --- /dev/null +++ b/src/govoplan_records/backend/schemas.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class FilePlanNodeWriteRequest(StrictModel): + node_id: str = Field(min_length=1, max_length=255) + code: str = Field(min_length=1, max_length=120) + label: str = Field(min_length=1, max_length=500) + parent_node_id: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=10_000) + active: bool = True + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime + expected_revision: int | None = Field(default=None, ge=1) + idempotency_key: str = Field(min_length=1, max_length=255) + institutional_context: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_interval(self): + if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: + raise ValueError("valid_to must be after valid_from") + return self + + +class RecordClassWriteRequest(StrictModel): + class_id: str = Field(min_length=1, max_length=255) + file_plan_node_id: str = Field(min_length=1, max_length=255) + key: str = Field(min_length=1, max_length=120) + label: str = Field(min_length=1, max_length=500) + description: str | None = Field(default=None, max_length=10_000) + metadata_requirements: list[str] = Field(default_factory=list, max_length=100) + allowed_source_types: list[str] = Field(default_factory=list, max_length=100) + retention_period_days: int | None = Field(default=None, ge=0, le=365_000) + closure_trigger: str | None = Field(default=None, max_length=255) + access_mode: Literal["tenant", "restricted"] = "tenant" + active: bool = True + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime + expected_revision: int | None = Field(default=None, ge=1) + idempotency_key: str = Field(min_length=1, max_length=255) + institutional_context: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_interval(self): + if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: + raise ValueError("valid_to must be after valid_from") + return self + + +class RecordCreateRequest(StrictModel): + record_id: str | None = Field(default=None, max_length=255) + record_number: str = Field(min_length=1, max_length=255) + class_id: str = Field(min_length=1, max_length=255) + file_plan_node_id: str = Field(min_length=1, max_length=255) + title: str = Field(min_length=1, max_length=500) + description: str | None = Field(default=None, max_length=20_000) + state: Literal["planned", "open"] = "open" + source_authority_mode: Literal[ + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + "governance_overlay", + "linked_reference", + ] = "native_authoritative" + access_mode: Literal["tenant", "restricted"] = "tenant" + purpose: str = Field(min_length=1, max_length=255) + classification: str | None = Field(default=None, max_length=120) + responsible_unit_id: str | None = Field(default=None, max_length=255) + responsible_function_id: str | None = Field(default=None, max_length=255) + external_reference: dict[str, Any] = Field(default_factory=dict) + institutional_context: dict[str, Any] = Field(default_factory=dict) + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=2_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + @model_validator(mode="after") + def validate_interval(self): + if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: + raise ValueError("valid_to must be after valid_from") + return self + + +class RecordUpdateRequest(StrictModel): + expected_revision: int = Field(ge=1) + title: str | None = Field(default=None, min_length=1, max_length=500) + description: str | None = Field(default=None, max_length=20_000) + class_id: str | None = Field(default=None, min_length=1, max_length=255) + file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255) + state: Literal["planned", "open"] | None = None + access_mode: Literal["tenant", "restricted"] | None = None + purpose: str = Field(min_length=1, max_length=255) + classification: str | None = Field(default=None, max_length=120) + responsible_unit_id: str | None = Field(default=None, max_length=255) + responsible_function_id: str | None = Field(default=None, max_length=255) + institutional_context: dict[str, Any] | None = None + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=2_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + @model_validator(mode="after") + def validate_interval(self): + if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: + raise ValueError("valid_to must be after valid_from") + return self + + +class RecordVolumeCreateRequest(StrictModel): + volume_id: str | None = Field(default=None, max_length=255) + label: str = Field(min_length=1, max_length=500) + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime + purpose: str = Field(min_length=1, max_length=255) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class RecordSourceLocatorRequest(StrictModel): + source_module: str = Field(min_length=1, max_length=100) + resource_type: str = Field(min_length=1, max_length=120) + resource_id: str = Field(min_length=1, max_length=500) + source_revision: str = Field(min_length=1, max_length=255) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class RecordItemCreateRequest(StrictModel): + source: RecordSourceLocatorRequest + purpose: str = Field(min_length=1, max_length=255) + filing_reason: str = Field(min_length=1, max_length=2_000) + idempotency_key: str = Field(min_length=1, max_length=255) + volume_id: str | None = Field(default=None, max_length=255) + relationship: str = Field(default="contains", min_length=1, max_length=120) + institutional_context: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class RecordListResponse(StrictModel): + records: list[dict[str, Any]] + total: int + offset: int + limit: int + + +class RecordCatalogResponse(StrictModel): + file_plan: list[dict[str, Any]] + classes: list[dict[str, Any]] + + +class RecordDetailResponse(StrictModel): + record: dict[str, Any] + volumes: list[dict[str, Any]] + items: list[dict[str, Any]] + chronology: list[dict[str, Any]] + access_explanation: dict[str, Any] + + +class RecordSourceProviderResponse(StrictModel): + providers: list[dict[str, Any]] + + +__all__ = [ + "FilePlanNodeWriteRequest", + "RecordCatalogResponse", + "RecordClassWriteRequest", + "RecordCreateRequest", + "RecordDetailResponse", + "RecordItemCreateRequest", + "RecordListResponse", + "RecordSourceProviderResponse", + "RecordUpdateRequest", + "RecordVolumeCreateRequest", +] diff --git a/src/govoplan_records/backend/search_source.py b/src/govoplan_records/backend/search_source.py new file mode 100644 index 0000000..d81cae7 --- /dev/null +++ b/src/govoplan_records/backend/search_source.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from urllib.parse import quote + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchResourceType, +) +from govoplan_records.backend.db.models import RecordIdentity, RecordRevision + + +PROVIDER_ID = "records.objects" +RESOURCE_TYPE = "record" +READ_SCOPE = "records:workspace:read" +ADMIN_SCOPE = "records:workspace:admin" + + +class RecordsSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="records", + resource_type=RESOURCE_TYPE, + label="Records", + requires_authorization_recheck=True, + ), + ) + + def backfill( + self, + session: object, + *, + request: SearchBackfillRequest, + ) -> SearchBackfillPage: + if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE: + raise ValueError("Unsupported Records search source.") + db = _session(session) + statement = ( + select(RecordRevision, RecordIdentity) + .join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id) + .where( + RecordRevision.tenant_id == request.tenant_id, + RecordRevision.superseded_at.is_(None), + ) + ) + if request.cursor: + statement = statement.where(RecordRevision.record_id > request.cursor) + rows = list( + db.execute( + statement.order_by(RecordRevision.record_id).limit(request.limit + 1) + ).all() + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(RecordRevision.recorded_at)).where( + RecordRevision.tenant_id == request.tenant_id, + RecordRevision.superseded_at.is_(None), + ) + ) + return SearchBackfillPage( + documents=tuple(_document(row, identity) for row, identity in selected), + next_cursor=selected[-1][0].record_id if has_more and selected else None, + complete=not has_more, + high_watermark=high_watermark.isoformat() if high_watermark else None, + ) + + def authorize( + self, + session: object, + principal: object, + *, + requests: Sequence[SearchAuthorizationRequest], + ) -> Mapping[str, bool]: + decisions = {item.reference.key: False for item in requests} + if not isinstance(principal, ApiPrincipal) or not ( + principal.has(READ_SCOPE) or principal.has(ADMIN_SCOPE) + ): + return decisions + db = _session(session) + eligible = [ + request + for request in requests + if request.reference.tenant_id == principal.tenant_id + and request.reference.module_id == "records" + and request.reference.resource_type == RESOURCE_TYPE + ] + resource_ids = {request.reference.resource_id for request in eligible} + available_ids = ( + set( + db.scalars( + select(RecordRevision.record_id).where( + RecordRevision.tenant_id == principal.tenant_id, + RecordRevision.record_id.in_(resource_ids), + RecordRevision.superseded_at.is_(None), + ) + ).all() + ) + if resource_ids + else set() + ) + for request in eligible: + reference = request.reference + decisions[reference.key] = reference.resource_id in available_ids + return decisions + + +def create_records_search_source(_context: ModuleContext) -> RecordsSearchSource: + return RecordsSearchSource() + + +def _document(row: RecordRevision, identity: RecordIdentity) -> SearchDocument: + return SearchDocument( + tenant_id=row.tenant_id, + module_id="records", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=row.record_id, + title=row.title, + url=f"/records?recordId={quote(row.record_id, safe='')}", + summary=(row.description or identity.record_number)[:4000], + body=row.search_text[:200_000], + keywords=tuple( + value[:200] + for value in ( + identity.record_number, + row.classification or "", + row.state, + ) + if value + ), + visibility="restricted", + acl_tokens=(f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"), + metadata={ + "record_number": identity.record_number, + "class_id": row.class_id, + "file_plan_node_id": row.file_plan_node_id, + "state": row.state, + "classification": row.classification, + }, + source_revision=str(row.revision), + source_updated_at=row.recorded_at, + requires_authorization_recheck=True, + ) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Records search requires a SQLAlchemy session.") + return value + + +__all__ = ["PROVIDER_ID", "RecordsSearchSource", "create_records_search_source"] diff --git a/src/govoplan_records/backend/service.py b/src/govoplan_records/backend/service.py new file mode 100644 index 0000000..c7bb204 --- /dev/null +++ b/src/govoplan_records/backend/service.py @@ -0,0 +1,1335 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.records import ( + RecordContractError, + RecordFilingRequest, + RecordFilingResult, + RecordSourceLocator, + RecordSourceProvider, + RecordSourceReference, + record_source_capabilities, + record_source_capability, +) +from govoplan_core.core.temporal import current_temporal_data_context +from govoplan_core.db.temporal import apply_temporal_revision_filter +from govoplan_records.backend.db.models import ( + RecordChronologyEntry, + RecordClassRevision, + RecordFilePlanRevision, + RecordIdentity, + RecordItem, + RecordRevision, + RecordVolumeRevision, +) + + +class RecordStoreError(ValueError): + pass + + +class RecordConflictError(RecordStoreError): + pass + + +class RecordNotFoundError(RecordStoreError): + pass + + +class RecordSourceUnavailableError(RecordStoreError): + pass + + +class SqlRecordRegistry: + def __init__(self, registry: object | None = None) -> None: + self.registry = registry + + def catalog( + self, session: Session, principal: object + ) -> dict[str, list[dict[str, Any]]]: + tenant_id = _tenant(principal) + file_plan_query = session.query(RecordFilePlanRevision).filter( + RecordFilePlanRevision.tenant_id == tenant_id + ) + class_query = session.query(RecordClassRevision).filter( + RecordClassRevision.tenant_id == tenant_id + ) + file_plan = ( + apply_temporal_revision_filter(file_plan_query, RecordFilePlanRevision) + .order_by(RecordFilePlanRevision.code, RecordFilePlanRevision.label) + .all() + ) + classes = ( + apply_temporal_revision_filter(class_query, RecordClassRevision) + .order_by(RecordClassRevision.label) + .all() + ) + return { + "file_plan": [_file_plan_dict(item) for item in file_plan], + "classes": [_class_dict(item) for item in classes], + } + + def write_file_plan_node( + self, + session: Session, + principal: object, + *, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + request_hash = _request_hash(payload) + replay = ( + session.query(RecordFilePlanRevision) + .filter( + RecordFilePlanRevision.tenant_id == tenant_id, + RecordFilePlanRevision.idempotency_key + == str(payload["idempotency_key"]), + ) + .one_or_none() + ) + if replay is not None: + _verify_replay(replay.request_sha256, request_hash) + return _file_plan_dict(replay) + + node_id = _text(payload, "node_id") + current = _current_file_plan(session, tenant_id, node_id, lock=True) + expected = payload.get("expected_revision") + _validate_expected( + current.revision if current else None, expected, label="File-plan node" + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + _validate_later_revision(current.recorded_at if current else None, recorded_at) + parent_node_id = _optional_text(payload.get("parent_node_id")) + _validate_file_plan_parent( + session, + tenant_id=tenant_id, + node_id=node_id, + parent_node_id=parent_node_id, + ) + duplicate = ( + session.query(RecordFilePlanRevision) + .filter( + RecordFilePlanRevision.tenant_id == tenant_id, + RecordFilePlanRevision.code == _text(payload, "code"), + RecordFilePlanRevision.node_id != node_id, + RecordFilePlanRevision.superseded_at.is_(None), + ) + .first() + ) + if duplicate is not None: + raise RecordConflictError( + "A current file-plan node already uses this code." + ) + if current is not None: + current.superseded_at = recorded_at + row = RecordFilePlanRevision( + tenant_id=tenant_id, + node_id=node_id, + revision=(current.revision + 1) if current else 1, + previous_revision_id=current.id if current else None, + parent_node_id=parent_node_id, + code=_text(payload, "code"), + label=_text(payload, "label"), + description=_optional_text(payload.get("description")), + active=bool(payload.get("active", True)), + valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from"), + valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"), + recorded_at=recorded_at, + institutional_context=_mapping(payload.get("institutional_context")), + changed_by=_actor(principal), + idempotency_key=_text(payload, "idempotency_key"), + request_sha256=request_hash, + ) + _validate_interval(row.valid_from, row.valid_to) + session.add(row) + session.flush() + return _file_plan_dict(row) + + def write_record_class( + self, + session: Session, + principal: object, + *, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + request_hash = _request_hash(payload) + replay = ( + session.query(RecordClassRevision) + .filter( + RecordClassRevision.tenant_id == tenant_id, + RecordClassRevision.idempotency_key == str(payload["idempotency_key"]), + ) + .one_or_none() + ) + if replay is not None: + _verify_replay(replay.request_sha256, request_hash) + return _class_dict(replay) + class_id = _text(payload, "class_id") + node_id = _text(payload, "file_plan_node_id") + if _current_file_plan(session, tenant_id, node_id) is None: + raise RecordStoreError( + "The record class requires an existing file-plan node." + ) + current = _current_class(session, tenant_id, class_id, lock=True) + _validate_expected( + current.revision if current else None, + payload.get("expected_revision"), + label="Record class", + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + _validate_later_revision(current.recorded_at if current else None, recorded_at) + duplicate = ( + session.query(RecordClassRevision) + .filter( + RecordClassRevision.tenant_id == tenant_id, + RecordClassRevision.key == _text(payload, "key"), + RecordClassRevision.class_id != class_id, + RecordClassRevision.superseded_at.is_(None), + ) + .first() + ) + if duplicate is not None: + raise RecordConflictError("A current record class already uses this key.") + if current is not None: + current.superseded_at = recorded_at + row = RecordClassRevision( + tenant_id=tenant_id, + class_id=class_id, + revision=(current.revision + 1) if current else 1, + previous_revision_id=current.id if current else None, + file_plan_node_id=node_id, + key=_text(payload, "key"), + label=_text(payload, "label"), + description=_optional_text(payload.get("description")), + metadata_requirements=_text_list(payload.get("metadata_requirements")), + allowed_source_types=_text_list(payload.get("allowed_source_types")), + retention_period_days=_optional_int(payload.get("retention_period_days")), + closure_trigger=_optional_text(payload.get("closure_trigger")), + access_mode=_text(payload, "access_mode", default="tenant"), + active=bool(payload.get("active", True)), + valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from"), + valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"), + recorded_at=recorded_at, + institutional_context=_mapping(payload.get("institutional_context")), + changed_by=_actor(principal), + idempotency_key=_text(payload, "idempotency_key"), + request_sha256=request_hash, + ) + _validate_interval(row.valid_from, row.valid_to) + session.add(row) + session.flush() + return _class_dict(row) + + def create_record( + self, + session: Session, + principal: object, + *, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + request_hash = _request_hash(payload) + replay = _replay_event( + session, + tenant_id=tenant_id, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + ) + if replay is not None: + return self.get_record( + session, + principal, + record_id=replay.record_id, + revision=replay.record_revision, + )["record"] + class_id = _text(payload, "class_id") + node_id = _text(payload, "file_plan_node_id") + record_class = _current_class(session, tenant_id, class_id) + if record_class is None or not record_class.active: + raise RecordStoreError("The selected record class is not available.") + if record_class.file_plan_node_id != node_id: + raise RecordStoreError( + "The record class does not belong to the selected file-plan node." + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + record_id = _optional_text(payload.get("record_id")) or str(uuid.uuid4()) + if _text(payload, "access_mode", default="tenant") != "tenant": + raise RecordStoreError( + "Restricted records require the object-grant slice and cannot be created yet." + ) + duplicate_number = ( + session.query(RecordIdentity.id) + .filter( + RecordIdentity.tenant_id == tenant_id, + RecordIdentity.record_number == _text(payload, "record_number"), + ) + .first() + ) + if duplicate_number is not None: + raise RecordConflictError("A record already uses this record number.") + identity = RecordIdentity( + tenant_id=tenant_id, + record_id=record_id, + record_number=_text(payload, "record_number"), + created_by=_actor(principal), + ) + session.add(identity) + session.flush() + snapshot = _record_snapshot(payload) + row = RecordRevision( + tenant_id=tenant_id, + record_id=record_id, + identity_id=identity.id, + revision=1, + class_id=class_id, + file_plan_node_id=node_id, + title=_text(payload, "title"), + description=_optional_text(payload.get("description")), + state=_text(payload, "state", default="open"), + source_authority_mode=_text( + payload, "source_authority_mode", default="native_authoritative" + ), + access_mode=_text(payload, "access_mode", default="tenant"), + purpose=_text(payload, "purpose"), + classification=_optional_text(payload.get("classification")), + responsible_unit_id=_optional_text(payload.get("responsible_unit_id")), + responsible_function_id=_optional_text( + payload.get("responsible_function_id") + ), + external_reference=_mapping(payload.get("external_reference")), + institutional_context=_mapping(payload.get("institutional_context")), + search_text=_record_search_text(payload, identity.record_number), + valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from") + or recorded_at, + valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"), + recorded_at=recorded_at, + changed_by=_actor(principal), + snapshot=snapshot, + ) + _validate_interval(row.valid_from, row.valid_to) + session.add(row) + _append_event( + session, + principal, + row=row, + event_type="record.created", + summary=f"Record {identity.record_number} created", + occurred_at=recorded_at, + purpose=row.purpose, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + institutional_context=row.institutional_context, + payload={"change_reason": _text(payload, "change_reason")}, + ) + session.flush() + return _record_dict(row, identity) + + def update_record( + self, + session: Session, + principal: object, + *, + record_id: str, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + request_hash = _request_hash({"record_id": record_id, **payload}) + replay = _replay_event( + session, + tenant_id=tenant_id, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + ) + if replay is not None: + return self.get_record( + session, + principal, + record_id=record_id, + revision=replay.record_revision, + )["record"] + current = _current_record(session, tenant_id, record_id, lock=True) + if current is None: + raise RecordNotFoundError("Record not found.") + expected = int(payload.get("expected_revision") or 0) + if current.revision != expected: + raise RecordConflictError( + f"Record revision conflict: expected {expected}, current revision is {current.revision}." + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + _validate_later_revision(current.recorded_at, recorded_at) + identity = session.get(RecordIdentity, current.identity_id) + if identity is None: + raise RecordStoreError("Record identity is missing.") + values = _revision_values(current, payload) + if values["access_mode"] != "tenant": + raise RecordStoreError( + "Restricted records require the object-grant slice and cannot be enabled yet." + ) + record_class = _current_class(session, tenant_id, values["class_id"]) + if ( + record_class is None + or record_class.file_plan_node_id != values["file_plan_node_id"] + ): + raise RecordStoreError( + "The selected record class and file-plan node do not match." + ) + current.superseded_at = recorded_at + row = RecordRevision( + tenant_id=tenant_id, + record_id=record_id, + identity_id=current.identity_id, + revision=current.revision + 1, + previous_revision_id=current.id, + recorded_at=recorded_at, + changed_by=_actor(principal), + search_text=" ".join( + value + for value in ( + identity.record_number, + str(values["title"]), + str(values["description"] or ""), + str(values["classification"] or ""), + ) + if value + ).lower(), + snapshot={**dict(current.snapshot), **_json_mapping(payload)}, + **values, + ) + _validate_interval(row.valid_from, row.valid_to) + session.add(row) + _append_event( + session, + principal, + row=row, + event_type="record.revised", + summary=f"Record {identity.record_number} revised", + occurred_at=recorded_at, + purpose=_text(payload, "purpose"), + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + institutional_context=row.institutional_context, + payload={ + "change_reason": _text(payload, "change_reason"), + "previous_revision": current.revision, + }, + ) + session.flush() + return _record_dict(row, identity) + + def list_records( + self, + session: Session, + principal: object, + *, + query: str | None = None, + state: str | None = None, + class_id: str | None = None, + file_plan_node_id: str | None = None, + offset: int = 0, + limit: int = 100, + ) -> tuple[list[dict[str, Any]], int]: + tenant_id = _tenant(principal) + statement = ( + session.query(RecordRevision, RecordIdentity) + .join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id) + .filter(RecordRevision.tenant_id == tenant_id) + ) + statement = apply_temporal_revision_filter(statement, RecordRevision) + if query and query.strip(): + pattern = f"%{query.strip().lower()}%" + statement = statement.filter(RecordRevision.search_text.ilike(pattern)) + if state: + statement = statement.filter(RecordRevision.state == state) + if class_id: + statement = statement.filter(RecordRevision.class_id == class_id) + if file_plan_node_id: + statement = statement.filter( + RecordRevision.file_plan_node_id == file_plan_node_id + ) + total = statement.count() + rows = ( + statement.order_by( + RecordIdentity.record_number, RecordRevision.recorded_at.desc() + ) + .offset(offset) + .limit(limit) + .all() + ) + return [_record_dict(row, identity) for row, identity in rows], total + + def get_record( + self, + session: Session, + principal: object, + *, + record_id: str, + revision: int | None = None, + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + query = session.query(RecordRevision).filter( + RecordRevision.tenant_id == tenant_id, + RecordRevision.record_id == record_id, + ) + if revision is None: + query = apply_temporal_revision_filter(query, RecordRevision) + else: + query = query.filter(RecordRevision.revision == revision) + row = query.order_by(RecordRevision.recorded_at.desc()).first() + if row is None: + raise RecordNotFoundError( + "Record not found in the selected temporal context." + ) + identity = session.get(RecordIdentity, row.identity_id) + if identity is None: + raise RecordStoreError("Record identity is missing.") + volumes = _record_volumes(session, tenant_id=tenant_id, record_id=record_id) + items = _record_items(session, tenant_id=tenant_id, record_id=record_id) + chronology = _record_chronology( + session, tenant_id=tenant_id, record_id=record_id + ) + return { + "record": _record_dict(row, identity), + "volumes": [_volume_dict(item) for item in volumes], + "items": [_item_dict(item) for item in items], + "chronology": [_chronology_dict(item) for item in chronology], + "access_explanation": { + "decision": "allowed", + "reason": "Current tenant and Records permission were evaluated for this read.", + "purpose": row.purpose, + "current_authorization": True, + "access_mode": row.access_mode, + "limitations": ( + ["Object-level restricted-record grants are not yet implemented."] + if row.access_mode == "restricted" + else [] + ), + }, + } + + def create_volume( + self, + session: Session, + principal: object, + *, + record_id: str, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + request_hash = _request_hash({"record_id": record_id, **payload}) + replay = _replay_event( + session, + tenant_id=tenant_id, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + ) + if replay is not None: + volume_id = str(replay.payload.get("volume_id") or "") + row = _current_volume(session, tenant_id, volume_id) + if row is None: + raise RecordStoreError("Replayed record volume is missing.") + return _volume_dict(row) + record = _current_record(session, tenant_id, record_id, lock=True) + if record is None: + raise RecordNotFoundError("Record not found.") + sequence = ( + int( + session.query(func.max(RecordVolumeRevision.sequence)) + .filter( + RecordVolumeRevision.tenant_id == tenant_id, + RecordVolumeRevision.record_id == record_id, + RecordVolumeRevision.superseded_at.is_(None), + ) + .scalar() + or 0 + ) + + 1 + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + row = RecordVolumeRevision( + tenant_id=tenant_id, + volume_id=_optional_text(payload.get("volume_id")) or str(uuid.uuid4()), + record_id=record_id, + revision=1, + sequence=sequence, + label=_text(payload, "label"), + state="open", + valid_from=_optional_timestamp(payload.get("valid_from"), "valid_from") + or recorded_at, + valid_to=_optional_timestamp(payload.get("valid_to"), "valid_to"), + recorded_at=recorded_at, + changed_by=_actor(principal), + ) + _validate_interval(row.valid_from, row.valid_to) + session.add(row) + _append_event( + session, + principal, + row=record, + event_type="record.volume_created", + summary=f"Volume {row.label} created", + occurred_at=recorded_at, + purpose=_text(payload, "purpose"), + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + institutional_context=record.institutional_context, + payload={"volume_id": row.volume_id, "sequence": sequence}, + ) + session.flush() + return _volume_dict(row) + + def file( + self, + session: object, + principal: object, + *, + request: RecordFilingRequest, + ) -> RecordFilingResult: + if not isinstance(session, Session): + raise RecordStoreError("Record filing requires a database session.") + tenant_id = _tenant(principal) + if request.tenant_id != tenant_id: + raise RecordStoreError("Record filing cannot cross tenants.") + request_hash = _request_hash(_filing_request_mapping(request)) + replay = ( + session.query(RecordItem) + .filter( + RecordItem.tenant_id == tenant_id, + RecordItem.idempotency_key == request.idempotency_key, + ) + .one_or_none() + ) + if replay is not None: + _verify_replay(replay.request_sha256, request_hash) + return _filing_result(replay, replayed=True) + record = _current_record(session, tenant_id, request.record_id, lock=True) + if record is None: + raise RecordNotFoundError("Record not found.") + if record.state != "open": + raise RecordStoreError("Only open records accept new items.") + record_class = _current_class(session, tenant_id, record.class_id) + if record_class is None: + raise RecordStoreError("The current record class is unavailable.") + if request.volume_id: + volume = _current_volume(session, tenant_id, request.volume_id) + if volume is None or volume.record_id != request.record_id: + raise RecordStoreError( + "The selected record volume does not belong to this record." + ) + reference = self._resolve_source(session, principal, request=request) + source_key = ( + f"{reference.locator.source_module}:{reference.locator.resource_type}" + ) + if record_class.allowed_source_types and ( + source_key not in record_class.allowed_source_types + and reference.locator.resource_type not in record_class.allowed_source_types + ): + raise RecordStoreError("The record class does not permit this source type.") + sequence = ( + int( + session.query(func.max(RecordItem.sequence)) + .filter( + RecordItem.tenant_id == tenant_id, + RecordItem.record_id == request.record_id, + ) + .scalar() + or 0 + ) + + 1 + ) + filed_at = datetime.now(UTC) + row = RecordItem( + tenant_id=tenant_id, + record_id=request.record_id, + volume_id=request.volume_id, + sequence=sequence, + source_module=reference.locator.source_module, + resource_type=reference.locator.resource_type, + resource_id=reference.locator.resource_id, + source_revision=reference.locator.source_revision, + label=reference.label, + relationship=request.relationship, + filing_reason=request.filing_reason, + purpose=request.purpose, + authority_mode=reference.authority_mode, + content_sha256=(reference.content_sha256 or "").removeprefix("sha256:") + or None, + content_type=reference.content_type, + size_bytes=reference.size_bytes, + source_valid_from=reference.valid_from, + source_valid_to=reference.valid_to, + source_recorded_at=reference.recorded_at, + launch_url=reference.launch_url, + filed_at=filed_at, + filed_by=_actor(principal), + actor_assignment_id=_actor_assignment(principal), + actor_delegation_id=_actor_delegation(principal), + institutional_context=dict(request.institutional_context), + source_metadata=dict(reference.metadata), + filing_metadata=dict(request.metadata), + idempotency_key=request.idempotency_key, + request_sha256=request_hash, + ) + session.add(row) + session.flush() + _append_event( + session, + principal, + row=record, + event_type="record.item_filed", + summary=f"{reference.label} filed", + occurred_at=filed_at, + purpose=request.purpose, + idempotency_key=request.idempotency_key, + request_hash=request_hash, + institutional_context=dict(request.institutional_context), + payload={ + "item_id": row.id, + "sequence": sequence, + "source_module": row.source_module, + "resource_type": row.resource_type, + "resource_id": row.resource_id, + "source_revision": row.source_revision, + }, + ) + session.flush() + return _filing_result(row, replayed=False) + + def source_providers( + self, + session: Session, + principal: object, + ) -> list[dict[str, object]]: + tenant_id = _tenant(principal) + providers: list[dict[str, object]] = [] + for capability_name in record_source_capabilities(self.registry): + provider = self._tenant_capability(capability_name, session, tenant_id) + if not isinstance(provider, RecordSourceProvider): + continue + providers.append( + { + "id": provider.provider_id, + "source_module": capability_name.removeprefix("records.source."), + "resource_types": list(provider.resource_types()), + } + ) + return providers + + def _resolve_source( + self, + session: Session, + principal: object, + *, + request: RecordFilingRequest, + ) -> RecordSourceReference: + capability_name = record_source_capability(request.source.source_module) + provider = self._tenant_capability(capability_name, session, request.tenant_id) + if not isinstance(provider, RecordSourceProvider): + raise RecordSourceUnavailableError( + f"No enabled record source provider is available for {request.source.source_module}." + ) + if request.source.resource_type not in provider.resource_types(): + raise RecordStoreError( + "The record source provider does not support this resource type." + ) + try: + reference = provider.resolve( + session, + principal, + locator=request.source, + purpose=request.purpose, + ) + except RecordContractError as exc: + raise RecordStoreError(str(exc)) from exc + if reference.locator != request.source: + raise RecordStoreError( + "The source provider returned a different source revision." + ) + return reference + + def _tenant_capability( + self, name: str, session: Session, tenant_id: str + ) -> object | None: + if self.registry is None: + return None + try: + if hasattr(self.registry, "tenant_capability"): + return self.registry.tenant_capability( + name, session, tenant_id=tenant_id + ) + if hasattr(self.registry, "capability"): + return self.registry.capability(name) + except Exception as exc: + raise RecordSourceUnavailableError( + f"Record source provider {name} is unavailable: {exc}" + ) from exc + return None + + +def _current_file_plan( + session: Session, tenant_id: str, node_id: str, *, lock: bool = False +) -> RecordFilePlanRevision | None: + query = session.query(RecordFilePlanRevision).filter( + RecordFilePlanRevision.tenant_id == tenant_id, + RecordFilePlanRevision.node_id == node_id, + RecordFilePlanRevision.superseded_at.is_(None), + ) + return query.with_for_update().one_or_none() if lock else query.one_or_none() + + +def _validate_file_plan_parent( + session: Session, + *, + tenant_id: str, + node_id: str, + parent_node_id: str | None, +) -> None: + current_id = parent_node_id + visited: set[str] = set() + while current_id is not None: + if current_id == node_id or current_id in visited: + raise RecordStoreError("The file-plan parent would create a cycle.") + visited.add(current_id) + parent = _current_file_plan(session, tenant_id, current_id) + if parent is None: + raise RecordStoreError("The parent file-plan node does not exist.") + current_id = parent.parent_node_id + + +def _current_class( + session: Session, tenant_id: str, class_id: str, *, lock: bool = False +) -> RecordClassRevision | None: + query = session.query(RecordClassRevision).filter( + RecordClassRevision.tenant_id == tenant_id, + RecordClassRevision.class_id == class_id, + RecordClassRevision.superseded_at.is_(None), + ) + return query.with_for_update().one_or_none() if lock else query.one_or_none() + + +def _current_record( + session: Session, tenant_id: str, record_id: str, *, lock: bool = False +) -> RecordRevision | None: + query = session.query(RecordRevision).filter( + RecordRevision.tenant_id == tenant_id, + RecordRevision.record_id == record_id, + RecordRevision.superseded_at.is_(None), + ) + return query.with_for_update().one_or_none() if lock else query.one_or_none() + + +def _current_volume( + session: Session, tenant_id: str, volume_id: str +) -> RecordVolumeRevision | None: + return ( + session.query(RecordVolumeRevision) + .filter( + RecordVolumeRevision.tenant_id == tenant_id, + RecordVolumeRevision.volume_id == volume_id, + RecordVolumeRevision.superseded_at.is_(None), + ) + .one_or_none() + ) + + +def _record_volumes( + session: Session, *, tenant_id: str, record_id: str +) -> Sequence[RecordVolumeRevision]: + query = session.query(RecordVolumeRevision).filter( + RecordVolumeRevision.tenant_id == tenant_id, + RecordVolumeRevision.record_id == record_id, + ) + return ( + apply_temporal_revision_filter(query, RecordVolumeRevision) + .order_by(RecordVolumeRevision.sequence) + .all() + ) + + +def _record_items( + session: Session, *, tenant_id: str, record_id: str +) -> Sequence[RecordItem]: + context = current_temporal_data_context() + query = session.query(RecordItem).filter( + RecordItem.tenant_id == tenant_id, + RecordItem.record_id == record_id, + ) + if context.recorded_at is not None: + query = query.filter(RecordItem.filed_at <= context.recorded_at) + instant = context.validity_instant + if instant is not None: + query = query.filter( + or_( + RecordItem.source_valid_from.is_(None), + RecordItem.source_valid_from <= instant, + ), + or_( + RecordItem.source_valid_to.is_(None), + RecordItem.source_valid_to > instant, + ), + ) + return query.order_by(RecordItem.sequence).all() + + +def _record_chronology( + session: Session, *, tenant_id: str, record_id: str +) -> Sequence[RecordChronologyEntry]: + context = current_temporal_data_context() + query = session.query(RecordChronologyEntry).filter( + RecordChronologyEntry.tenant_id == tenant_id, + RecordChronologyEntry.record_id == record_id, + ) + if context.recorded_at is not None: + query = query.filter(RecordChronologyEntry.occurred_at <= context.recorded_at) + return query.order_by(RecordChronologyEntry.occurred_at.desc()).all() + + +def _append_event( + session: Session, + principal: object, + *, + row: RecordRevision, + event_type: str, + summary: str, + occurred_at: datetime, + purpose: str, + idempotency_key: str, + request_hash: str, + institutional_context: Mapping[str, object], + payload: Mapping[str, object], +) -> RecordChronologyEntry: + event = RecordChronologyEntry( + tenant_id=row.tenant_id, + record_id=row.record_id, + event_id=str(uuid.uuid4()), + event_type=event_type, + record_revision=row.revision, + summary=summary[:500], + occurred_at=occurred_at, + actor_id=_actor(principal), + actor_assignment_id=_actor_assignment(principal), + actor_delegation_id=_actor_delegation(principal), + purpose=purpose, + idempotency_key=idempotency_key, + request_sha256=request_hash, + institutional_context=dict(institutional_context), + payload=dict(payload), + ) + session.add(event) + return event + + +def _replay_event( + session: Session, + *, + tenant_id: str, + idempotency_key: str, + request_hash: str, +) -> RecordChronologyEntry | None: + event = ( + session.query(RecordChronologyEntry) + .filter( + RecordChronologyEntry.tenant_id == tenant_id, + RecordChronologyEntry.idempotency_key == idempotency_key, + ) + .one_or_none() + ) + if event is not None: + _verify_replay(event.request_sha256, request_hash) + return event + + +def _verify_replay(actual_hash: str, requested_hash: str) -> None: + if actual_hash != requested_hash: + raise RecordConflictError( + "The idempotency key was already used with a different request." + ) + + +def _validate_expected(current: int | None, expected: object, *, label: str) -> None: + if current is None: + if expected is not None: + raise RecordConflictError( + f"{label} revision conflict: no current revision exists." + ) + return + if expected is None or int(expected) != current: + raise RecordConflictError( + f"{label} revision conflict: expected {expected}, current revision is {current}." + ) + + +def _validate_later_revision(previous: datetime | None, current: datetime) -> None: + if previous is not None and _aware(current) <= _aware(previous): + raise RecordConflictError( + "A new revision must be recorded after the current revision." + ) + + +def _validate_interval(valid_from: datetime | None, valid_to: datetime | None) -> None: + if valid_from and valid_to and _aware(valid_to) <= _aware(valid_from): + raise RecordStoreError("valid_to must be after valid_from.") + + +def _revision_values( + current: RecordRevision, payload: Mapping[str, object] +) -> dict[str, Any]: + def selected(name: str) -> object: + return payload[name] if name in payload else getattr(current, name) + + return { + "class_id": str(selected("class_id")), + "file_plan_node_id": str(selected("file_plan_node_id")), + "title": str(selected("title")), + "description": _optional_text(selected("description")), + "state": str(selected("state")), + "source_authority_mode": current.source_authority_mode, + "access_mode": str(selected("access_mode")), + "purpose": current.purpose, + "classification": _optional_text(selected("classification")), + "responsible_unit_id": _optional_text(selected("responsible_unit_id")), + "responsible_function_id": _optional_text(selected("responsible_function_id")), + "external_reference": dict(current.external_reference), + "institutional_context": _mapping(selected("institutional_context")), + "valid_from": ( + _optional_timestamp(payload.get("valid_from"), "valid_from") + if "valid_from" in payload + else current.valid_from + ), + "valid_to": ( + _optional_timestamp(payload.get("valid_to"), "valid_to") + if "valid_to" in payload + else current.valid_to + ), + } + + +def _record_snapshot(payload: Mapping[str, object]) -> dict[str, Any]: + return _json_mapping(payload) + + +def _record_search_text(payload: Mapping[str, object], record_number: str) -> str: + return " ".join( + value + for value in ( + record_number, + str(payload.get("title") or ""), + str(payload.get("description") or ""), + str(payload.get("classification") or ""), + ) + if value + ).lower() + + +def _file_plan_dict(row: RecordFilePlanRevision) -> dict[str, Any]: + return { + "node_id": row.node_id, + "revision": row.revision, + "parent_node_id": row.parent_node_id, + "code": row.code, + "label": row.label, + "description": row.description, + "active": row.active, + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + "recorded_at": _datetime_text(row.recorded_at), + "institutional_context": dict(row.institutional_context), + } + + +def _class_dict(row: RecordClassRevision) -> dict[str, Any]: + return { + "class_id": row.class_id, + "revision": row.revision, + "file_plan_node_id": row.file_plan_node_id, + "key": row.key, + "label": row.label, + "description": row.description, + "metadata_requirements": list(row.metadata_requirements), + "allowed_source_types": list(row.allowed_source_types), + "retention_period_days": row.retention_period_days, + "closure_trigger": row.closure_trigger, + "access_mode": row.access_mode, + "active": row.active, + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + "recorded_at": _datetime_text(row.recorded_at), + "institutional_context": dict(row.institutional_context), + } + + +def _record_dict(row: RecordRevision, identity: RecordIdentity) -> dict[str, Any]: + return { + "reference": { + "kind": "record", + "owner_module": "records", + "object_id": row.record_id, + "tenant_id": row.tenant_id, + "version": str(row.revision), + }, + "record_id": row.record_id, + "record_number": identity.record_number, + "revision": row.revision, + "class_id": row.class_id, + "file_plan_node_id": row.file_plan_node_id, + "title": row.title, + "description": row.description, + "state": row.state, + "source_authority_mode": row.source_authority_mode, + "access_mode": row.access_mode, + "purpose": row.purpose, + "classification": row.classification, + "responsible_unit_id": row.responsible_unit_id, + "responsible_function_id": row.responsible_function_id, + "external_reference": dict(row.external_reference), + "institutional_context": dict(row.institutional_context), + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + "recorded_at": _datetime_text(row.recorded_at), + } + + +def _volume_dict(row: RecordVolumeRevision) -> dict[str, Any]: + return { + "volume_id": row.volume_id, + "record_id": row.record_id, + "revision": row.revision, + "sequence": row.sequence, + "label": row.label, + "state": row.state, + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + "recorded_at": _datetime_text(row.recorded_at), + } + + +def _item_dict(row: RecordItem) -> dict[str, Any]: + return { + "item_id": row.id, + "record_id": row.record_id, + "volume_id": row.volume_id, + "sequence": row.sequence, + "source": { + "source_module": row.source_module, + "resource_type": row.resource_type, + "resource_id": row.resource_id, + "source_revision": row.source_revision, + }, + "label": row.label, + "relationship": row.relationship, + "filing_reason": row.filing_reason, + "purpose": row.purpose, + "authority_mode": row.authority_mode, + "content_sha256": row.content_sha256, + "content_type": row.content_type, + "size_bytes": row.size_bytes, + "source_valid_from": _datetime_text(row.source_valid_from), + "source_valid_to": _datetime_text(row.source_valid_to), + "source_recorded_at": _datetime_text(row.source_recorded_at), + "launch_url": row.launch_url, + "filed_at": _datetime_text(row.filed_at), + "filed_by": row.filed_by, + "institutional_context": dict(row.institutional_context), + "source_metadata": dict(row.source_metadata), + "filing_metadata": dict(row.filing_metadata), + } + + +def _chronology_dict(row: RecordChronologyEntry) -> dict[str, Any]: + return { + "event_id": row.event_id, + "event_type": row.event_type, + "record_revision": row.record_revision, + "summary": row.summary, + "occurred_at": _datetime_text(row.occurred_at), + "actor_id": row.actor_id, + "purpose": row.purpose, + "institutional_context": dict(row.institutional_context), + "payload": dict(row.payload), + } + + +def _filing_request_mapping(request: RecordFilingRequest) -> dict[str, object]: + return { + "tenant_id": request.tenant_id, + "record_id": request.record_id, + "source": { + "tenant_id": request.source.tenant_id, + "source_module": request.source.source_module, + "resource_type": request.source.resource_type, + "resource_id": request.source.resource_id, + "source_revision": request.source.source_revision, + "metadata": dict(request.source.metadata), + }, + "purpose": request.purpose, + "filing_reason": request.filing_reason, + "idempotency_key": request.idempotency_key, + "volume_id": request.volume_id, + "relationship": request.relationship, + "institutional_context": dict(request.institutional_context), + "metadata": dict(request.metadata), + } + + +def _filing_result(row: RecordItem, *, replayed: bool) -> RecordFilingResult: + locator = RecordSourceLocator( + tenant_id=row.tenant_id, + source_module=row.source_module, + resource_type=row.resource_type, + resource_id=row.resource_id, + source_revision=row.source_revision, + ) + reference = RecordSourceReference( + locator=locator, + label=row.label, + authority_mode=row.authority_mode, # type: ignore[arg-type] + content_sha256=row.content_sha256, + content_type=row.content_type, + size_bytes=row.size_bytes, + valid_from=row.source_valid_from, + valid_to=row.source_valid_to, + recorded_at=row.source_recorded_at, + launch_url=row.launch_url, + metadata=dict(row.source_metadata), + ) + return RecordFilingResult( + record_id=row.record_id, + item_id=row.id, + sequence=row.sequence, + source=reference, + filed_at=_aware(row.filed_at), + replayed=replayed, + ) + + +def _tenant(principal: object) -> str: + value = _principal_value(principal, "tenant_id") + if not value: + raise RecordStoreError("Records operations require a tenant-bound principal.") + return value + + +def _actor(principal: object) -> str | None: + for name in ("account_id", "identity_id", "membership_id"): + value = _principal_value(principal, name) + if value: + return value + return None + + +def _actor_assignment(principal: object) -> str | None: + return _principal_value(principal, "acting_assignment_id") or _principal_value( + principal, "assignment_id" + ) + + +def _actor_delegation(principal: object) -> str | None: + return _principal_value(principal, "acting_delegation_id") or _principal_value( + principal, "delegation_id" + ) + + +def _principal_value(principal: object, name: str) -> str | None: + value = str(getattr(principal, name, "") or "").strip() + return value or None + + +def _text( + payload: Mapping[str, object], name: str, *, default: str | None = None +) -> str: + value = str(payload.get(name, default) or "").strip() + if not value: + raise RecordStoreError(f"{name} is required.") + return value + + +def _optional_text(value: object) -> str | None: + text = str(value or "").strip() + return text or None + + +def _text_list(value: object) -> list[str]: + if value is None: + return [] + if not isinstance(value, (list, tuple)): + raise RecordStoreError("Expected a list of text values.") + return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + + +def _mapping(value: object) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise RecordStoreError("Expected an object value.") + return _json_mapping(value) + + +def _json_mapping(value: Mapping[str, object]) -> dict[str, Any]: + return json.loads(json.dumps(dict(value), default=_json_default)) + + +def _json_default(value: object) -> str: + if isinstance(value, datetime): + return _datetime_text(value) or "" + return str(value) + + +def _request_hash(payload: Mapping[str, object]) -> str: + encoded = json.dumps( + dict(payload), sort_keys=True, separators=(",", ":"), default=_json_default + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _timestamp(value: object, name: str) -> datetime: + parsed = _optional_timestamp(value, name) + if parsed is None: + raise RecordStoreError(f"{name} is required.") + return parsed + + +def _optional_timestamp(value: object, name: str) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + parsed = value + else: + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as exc: + raise RecordStoreError(f"{name} must be an ISO 8601 timestamp.") from exc + if parsed.tzinfo is None: + raise RecordStoreError(f"{name} must include a timezone.") + return parsed.astimezone(UTC) + + +def _optional_int(value: object) -> int | None: + return None if value is None or value == "" else int(value) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _datetime_text(value: datetime | None) -> str | None: + return ( + _aware(value).isoformat().replace("+00:00", "Z") if value is not None else None + ) + + +__all__ = [ + "RecordConflictError", + "RecordNotFoundError", + "RecordSourceUnavailableError", + "RecordStoreError", + "SqlRecordRegistry", +] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 92d042b..bf57796 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,22 +2,44 @@ from __future__ import annotations import unittest -from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest +from govoplan_core.core.records import CAPABILITY_RECORDS_FILING +from govoplan_records.backend.manifest import ( + ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + get_manifest, +) -class ManifestSeedTests(unittest.TestCase): - def test_manifest_registers_seed_contract(self) -> None: +class ManifestTests(unittest.TestCase): + def test_manifest_registers_records_vertical(self) -> None: manifest = get_manifest() - self.assertEqual(manifest.id, "records") - self.assertEqual(manifest.name, "Records") - self.assertEqual(manifest.dependencies, ("access",)) - self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) - self.assertEqual({role.slug for role in manifest.role_templates}, {"records_manager", "records_viewer"}) - self.assertTrue(manifest.documentation) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.migration_spec) - self.assertIsNone(manifest.frontend) + self.assertEqual("records", manifest.id) + self.assertEqual("Records", manifest.name) + self.assertEqual(("access",), manifest.dependencies) + self.assertEqual( + {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, + {permission.scope for permission in manifest.permissions}, + ) + self.assertEqual( + {"records_manager", "records_viewer", "records_administrator"}, + {role.slug for role in manifest.role_templates}, + ) + self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.migration_spec) + self.assertIsNotNone(manifest.frontend) + self.assertIn(CAPABILITY_RECORDS_FILING, manifest.capability_factories) + self.assertEqual( + "vertical_slice", + manifest.architecture.maturity if manifest.architecture else None, + ) + self.assertEqual( + "enforced", manifest.information_governance.temporal_browsing.adoption + ) + self.assertEqual( + "partial", manifest.information_governance.purpose_aware_access.adoption + ) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..8ab4f91 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_records.backend.manifest import get_manifest + + +class RecordsMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_records_kernel_and_head(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-records-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'records.db'}" + migrate_database( + database_url=url, + enabled_modules=("records",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + inspector = inspect(engine) + self.assertTrue( + { + "record_chronology_entries", + "record_class_revisions", + "record_file_plan_revisions", + "record_identities", + "record_items", + "record_revisions", + "record_volume_revisions", + }.issubset(inspector.get_table_names()) + ) + self.assertIn( + "source_revision", + {item["name"] for item in inspector.get_columns("record_items")}, + ) + with engine.connect() as connection: + self.assertIn( + "6e4a2c8f1d9b", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..1aff738 --- /dev/null +++ b/tests/test_records.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.records import ( + RecordFilingRequest, + RecordSourceLocator, + RecordSourceReference, +) +from govoplan_core.core.temporal import ( + TemporalDataContext, + bind_temporal_data_context, + reset_temporal_data_context, +) +from govoplan_records.backend.db.models import ( + RecordChronologyEntry, + RecordClassRevision, + RecordFilePlanRevision, + RecordIdentity, + RecordItem, + RecordRevision, + RecordVolumeRevision, +) +from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry + + +NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + acting_assignment_id: str = "assignment-1" + acting_delegation_id: str | None = None + + +class SourceProvider: + provider_id = "files" + + def resource_types(self): + return ("file_version",) + + def resolve(self, session, principal, *, locator, purpose): + del session, purpose + if principal.tenant_id != locator.tenant_id: + raise ValueError("Source access denied.") + return RecordSourceReference( + locator=locator, + label="Decision.pdf", + authority_mode="external_authoritative", + content_sha256="a" * 64, + content_type="application/pdf", + size_bytes=2048, + valid_from=NOW, + recorded_at=NOW, + launch_url="/files?fileId=file-1", + ) + + +class Registry: + def __init__(self) -> None: + self.provider = SourceProvider() + + def capability_names(self): + return ("records.source.files",) + + def tenant_capability(self, name, session, *, tenant_id): + del session + return ( + self.provider + if name == "records.source.files" and tenant_id == "tenant-1" + else None + ) + + +class RecordsTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + RecordFilePlanRevision.__table__, + RecordClassRevision.__table__, + RecordIdentity.__table__, + RecordRevision.__table__, + RecordVolumeRevision.__table__, + RecordItem.__table__, + RecordChronologyEntry.__table__, + ): + table.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + self.records = SqlRecordRegistry(Registry()) + self._seed_catalog() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _seed_catalog(self) -> None: + self.records.write_file_plan_node( + self.session, + self.principal, + payload={ + "node_id": "plan-permits", + "code": "10.20", + "label": "Permits", + "recorded_at": NOW, + "valid_from": NOW, + "idempotency_key": "catalog-node-1", + "institutional_context": {"organization_unit_id": "unit-1"}, + }, + ) + self.records.write_record_class( + self.session, + self.principal, + payload={ + "class_id": "class-permit", + "file_plan_node_id": "plan-permits", + "key": "permit.application", + "label": "Permit application", + "allowed_source_types": ["files:file_version"], + "retention_period_days": 3650, + "access_mode": "tenant", + "recorded_at": NOW, + "valid_from": NOW, + "idempotency_key": "catalog-class-1", + "institutional_context": {"organization_unit_id": "unit-1"}, + }, + ) + self.session.commit() + + def _create_record(self) -> dict[str, object]: + result = self.records.create_record( + self.session, + self.principal, + payload={ + "record_id": "record-1", + "record_number": "2026/0001", + "class_id": "class-permit", + "file_plan_node_id": "plan-permits", + "title": "Permit application Ada Example", + "description": "Assisted service journey", + "state": "open", + "source_authority_mode": "native_authoritative", + "access_mode": "tenant", + "purpose": "process permit application", + "institutional_context": { + "organization_unit_id": "unit-1", + "case_id": "case-1", + }, + "recorded_at": NOW + timedelta(minutes=1), + "valid_from": NOW, + "change_reason": "Application received.", + "idempotency_key": "record-create-1", + }, + ) + self.session.commit() + return result + + def test_record_creation_revision_and_temporal_reconstruction(self) -> None: + first = self._create_record() + replay = self._create_record() + self.assertEqual(first["record_id"], replay["record_id"]) + + revised = self.records.update_record( + self.session, + self.principal, + record_id="record-1", + payload={ + "expected_revision": 1, + "title": "Permit application Ada Example - verified", + "purpose": "verify application", + "recorded_at": NOW + timedelta(minutes=2), + "change_reason": "Identity verified.", + "idempotency_key": "record-update-1", + }, + ) + self.session.commit() + self.assertEqual(2, revised["revision"]) + self.assertEqual("process permit application", revised["purpose"]) + + token = bind_temporal_data_context( + TemporalDataContext( + validity_mode="at", + valid_at=NOW + timedelta(hours=1), + recorded_at=NOW + timedelta(minutes=1, seconds=30), + ) + ) + try: + historical = self.records.get_record( + self.session, self.principal, record_id="record-1" + ) + self.assertEqual(1, historical["record"]["revision"]) + self.assertEqual( + "Permit application Ada Example", historical["record"]["title"] + ) + finally: + reset_temporal_data_context(token) + + with self.assertRaisesRegex(RecordConflictError, "current revision is 2"): + self.records.update_record( + self.session, + self.principal, + record_id="record-1", + payload={ + "expected_revision": 1, + "title": "Stale title", + "purpose": "update record", + "recorded_at": NOW + timedelta(minutes=3), + "change_reason": "Stale edit.", + "idempotency_key": "record-update-stale", + }, + ) + + def test_update_replay_returns_the_original_revision(self) -> None: + self._create_record() + first_payload = { + "expected_revision": 1, + "title": "First revised title", + "purpose": "correct title", + "recorded_at": NOW + timedelta(minutes=2), + "change_reason": "First correction.", + "idempotency_key": "record-update-replay", + } + first = self.records.update_record( + self.session, + self.principal, + record_id="record-1", + payload=first_payload, + ) + self.records.update_record( + self.session, + self.principal, + record_id="record-1", + payload={ + "expected_revision": 2, + "title": "Second revised title", + "purpose": "correct title again", + "recorded_at": NOW + timedelta(minutes=3), + "change_reason": "Second correction.", + "idempotency_key": "record-update-second", + }, + ) + self.session.commit() + + replay = self.records.update_record( + self.session, + self.principal, + record_id="record-1", + payload=first_payload, + ) + + self.assertEqual(2, first["revision"]) + self.assertEqual(2, replay["revision"]) + self.assertEqual("First revised title", replay["title"]) + + def test_file_plan_revisions_reject_indirect_parent_cycles(self) -> None: + self.records.write_file_plan_node( + self.session, + self.principal, + payload={ + "node_id": "plan-child", + "parent_node_id": "plan-permits", + "code": "10.20.1", + "label": "Child", + "recorded_at": NOW + timedelta(minutes=1), + "idempotency_key": "catalog-child", + }, + ) + self.records.write_file_plan_node( + self.session, + self.principal, + payload={ + "node_id": "plan-grandchild", + "parent_node_id": "plan-child", + "code": "10.20.1.1", + "label": "Grandchild", + "recorded_at": NOW + timedelta(minutes=2), + "idempotency_key": "catalog-grandchild", + }, + ) + + with self.assertRaisesRegex(ValueError, "create a cycle"): + self.records.write_file_plan_node( + self.session, + self.principal, + payload={ + "node_id": "plan-permits", + "parent_node_id": "plan-grandchild", + "code": "10.20", + "label": "Permits", + "recorded_at": NOW + timedelta(minutes=3), + "expected_revision": 1, + "idempotency_key": "catalog-cycle", + }, + ) + + def test_exact_source_filing_is_authorized_idempotent_and_reconstructable( + self, + ) -> None: + self._create_record() + request = RecordFilingRequest( + tenant_id="tenant-1", + record_id="record-1", + source=RecordSourceLocator( + tenant_id="tenant-1", + source_module="files", + resource_type="file_version", + resource_id="file-1", + source_revision="version-7", + ), + purpose="document decision basis", + filing_reason="Submitted evidence belongs to the permit record.", + idempotency_key="filing-1", + institutional_context={"case_id": "case-1"}, + ) + first = self.records.file(self.session, self.principal, request=request) + self.session.commit() + second = self.records.file(self.session, self.principal, request=request) + self.assertEqual(first.item_id, second.item_id) + self.assertTrue(second.replayed) + + detail = self.records.get_record( + self.session, self.principal, record_id="record-1" + ) + self.assertEqual("version-7", detail["items"][0]["source"]["source_revision"]) + self.assertEqual("a" * 64, detail["items"][0]["content_sha256"]) + self.assertEqual( + ["record.item_filed", "record.created"], + [entry["event_type"] for entry in detail["chronology"]], + ) + + def test_filing_rejects_a_volume_from_another_record(self) -> None: + self._create_record() + second = self.records.create_record( + self.session, + self.principal, + payload={ + "record_id": "record-2", + "record_number": "2026/0002", + "class_id": "class-permit", + "file_plan_node_id": "plan-permits", + "title": "Second permit record", + "state": "open", + "purpose": "process second permit", + "recorded_at": NOW + timedelta(minutes=2), + "change_reason": "Second application received.", + "idempotency_key": "record-create-2", + }, + ) + volume = self.records.create_volume( + self.session, + self.principal, + record_id=str(second["record_id"]), + payload={ + "volume_id": "volume-2", + "label": "Second record volume", + "recorded_at": NOW + timedelta(minutes=3), + "purpose": "structure record", + "idempotency_key": "volume-create-2", + }, + ) + request = RecordFilingRequest( + tenant_id="tenant-1", + record_id="record-1", + volume_id=str(volume["volume_id"]), + source=RecordSourceLocator( + tenant_id="tenant-1", + source_module="files", + resource_type="file_version", + resource_id="file-1", + source_revision="version-7", + ), + purpose="document decision basis", + filing_reason="Attempted cross-record volume filing.", + idempotency_key="filing-cross-volume", + ) + + with self.assertRaisesRegex(ValueError, "does not belong to this record"): + self.records.file(self.session, self.principal, request=request) + + def test_tenant_isolation_and_source_discovery(self) -> None: + self._create_record() + records, total = self.records.list_records(self.session, Principal("tenant-2")) + self.assertEqual([], records) + self.assertEqual(0, total) + self.assertEqual( + [ + { + "id": "files", + "source_module": "files", + "resource_types": ["file_version"], + } + ], + self.records.source_providers(self.session, self.principal), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..d611eda --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_records.backend.db.models import RecordIdentity, RecordRevision +from govoplan_records.backend.search_source import ( + ADMIN_SCOPE, + PROVIDER_ID, + READ_SCOPE, + RESOURCE_TYPE, + RecordsSearchSource, +) + + +NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC) + + +class RecordsSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + RecordIdentity.__table__.create(self.engine) + RecordRevision.__table__.create(self.engine) + self.session = Session(self.engine) + identity = RecordIdentity( + id="identity-1", + tenant_id="tenant-1", + record_id="record-1", + record_number="EA-2026-0001", + ) + self.session.add_all( + ( + identity, + RecordRevision( + tenant_id="tenant-1", + record_id="record-1", + identity_id=identity.id, + revision=1, + class_id="class-1", + file_plan_node_id="plan-1", + title="Permit decision", + description="Decision record", + state="open", + source_authority_mode="native_authoritative", + access_mode="tenant", + purpose="case-work", + institutional_context={"organization_unit_id": "unit-1"}, + external_reference={}, + search_text="ea-2026-0001 permit decision", + valid_from=NOW, + recorded_at=NOW, + snapshot={}, + ), + RecordIdentity( + id="identity-2", + tenant_id="tenant-2", + record_id="record-2", + record_number="EA-OTHER", + ), + ) + ) + self.session.flush() + self.session.add( + RecordRevision( + tenant_id="tenant-2", + record_id="record-2", + identity_id="identity-2", + revision=1, + class_id="class-1", + file_plan_node_id="plan-1", + title="Other tenant", + state="open", + source_authority_mode="native_authoritative", + access_mode="tenant", + purpose="case-work", + institutional_context={}, + external_reference={}, + search_text="other tenant", + valid_from=NOW, + recorded_at=NOW, + snapshot={}, + ) + ) + self.session.commit() + self.source = RecordsSearchSource() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_backfill_and_live_authorization_are_tenant_scoped(self) -> None: + page = self.source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="records-rebuild-1", + ), + ) + self.assertEqual( + ("record-1",), tuple(document.resource_id for document in page.documents) + ) + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="records", + resource_type=RESOURCE_TYPE, + resource_id="record-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + + self.assertTrue( + self.source.authorize( + self.session, + _principal({READ_SCOPE}), + requests=(request,), + )[reference.key] + ) + self.assertTrue( + self.source.authorize( + self.session, + _principal({ADMIN_SCOPE}), + requests=(request,), + )[reference.key] + ) + self.assertFalse( + self.source.authorize( + self.session, + _principal(set()), + requests=(request,), + )[reference.key] + ) + other_tenant_reference = SearchResourceReference( + tenant_id="tenant-2", + module_id="records", + resource_type=RESOURCE_TYPE, + resource_id="record-2", + ) + self.assertFalse( + self.source.authorize( + self.session, + _principal({READ_SCOPE}), + requests=( + SearchAuthorizationRequest( + reference=other_tenant_reference, + source_revision="1", + ), + ), + )[other_tenant_reference.key] + ) + + +def _principal(scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="user-1"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..ef2866d --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/records-webui", + "version": "0.1.18", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/records.css": "./src/styles/records.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/records.ts b/webui/src/api/records.ts new file mode 100644 index 0000000..3e61ab3 --- /dev/null +++ b/webui/src/api/records.ts @@ -0,0 +1,184 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type FilePlanNode = { + node_id: string; + revision: number; + parent_node_id?: string | null; + code: string; + label: string; + description?: string | null; + active: boolean; + valid_from?: string | null; + valid_to?: string | null; + recorded_at: string; + institutional_context: Record; +}; + +export type RecordClass = { + class_id: string; + revision: number; + file_plan_node_id: string; + key: string; + label: string; + description?: string | null; + metadata_requirements: string[]; + allowed_source_types: string[]; + retention_period_days?: number | null; + closure_trigger?: string | null; + access_mode: "tenant" | "restricted"; + active: boolean; +}; + +export type RecordCatalog = { + file_plan: FilePlanNode[]; + classes: RecordClass[]; +}; + +export type RecordEntry = { + record_id: string; + record_number: string; + revision: number; + class_id: string; + file_plan_node_id: string; + title: string; + description?: string | null; + state: "planned" | "open" | string; + source_authority_mode: string; + access_mode: "tenant" | "restricted"; + purpose: string; + classification?: string | null; + responsible_unit_id?: string | null; + responsible_function_id?: string | null; + external_reference: Record; + institutional_context: Record; + valid_from?: string | null; + valid_to?: string | null; + recorded_at: string; +}; + +export type RecordVolume = { + volume_id: string; + sequence: number; + label: string; + state: string; + recorded_at: string; +}; + +export type RecordItem = { + item_id: string; + sequence: number; + volume_id?: string | null; + source: { + source_module: string; + resource_type: string; + resource_id: string; + source_revision: string; + }; + label: string; + relationship: string; + filing_reason: string; + purpose: string; + authority_mode: string; + content_sha256?: string | null; + content_type?: string | null; + size_bytes?: number | null; + launch_url?: string | null; + filed_at: string; + filed_by?: string | null; +}; + +export type RecordChronology = { + event_id: string; + event_type: string; + record_revision: number; + summary: string; + occurred_at: string; + actor_id?: string | null; + purpose: string; + payload: Record; +}; + +export type RecordDetail = { + record: RecordEntry; + volumes: RecordVolume[]; + items: RecordItem[]; + chronology: RecordChronology[]; + access_explanation: { + decision: string; + reason: string; + purpose: string; + current_authorization: boolean; + access_mode: string; + limitations: string[]; + }; +}; + +export type RecordSourceProvider = { + id: string; + source_module: string; + resource_types: string[]; +}; + +export function listRecords( + settings: ApiSettings, + options: { + query?: string; + state?: string; + classId?: string; + filePlanNodeId?: string; + offset?: number; + limit?: number; + }, + signal?: AbortSignal +): Promise<{ records: RecordEntry[]; total: number; offset: number; limit: number }> { + return apiFetch(settings, apiPath("/api/v1/records", { + query: options.query, + state: options.state, + class_id: options.classId, + file_plan_node_id: options.filePlanNodeId, + offset: options.offset, + limit: options.limit ?? 50 + }), { signal }); +} + +export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal }); +} + +export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise { + return apiFetch(settings, "/api/v1/records/catalog", { signal }); +} + +export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordSourceProvider[] }> { + return apiFetch(settings, "/api/v1/records/sources", { signal }); +} + +export function createRecord(settings: ApiSettings, payload: Record): Promise { + return apiFetch(settings, "/api/v1/records", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateRecord( + settings: ApiSettings, + recordId: string, + payload: Record +): Promise { + return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function fileRecordItem( + settings: ApiSettings, + recordId: string, + payload: Record +): Promise> { + return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/items`, { + method: "POST", + body: JSON.stringify(payload) + }); +} diff --git a/webui/src/features/records/RecordsPage.tsx b/webui/src/features/records/RecordsPage.tsx new file mode 100644 index 0000000..a6cc32e --- /dev/null +++ b/webui/src/features/records/RecordsPage.tsx @@ -0,0 +1,802 @@ +import { + Archive, + FilePlus2, + Pencil, + Plus, + RefreshCw, + Search +} from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type FormEvent +} from "react"; +import { useSearchParams } from "react-router"; +import { + Button, + DataGrid, + Dialog, + DismissibleAlert, + DocumentationHelpLink, + FormField, + i18nMessage, + LoadingIndicator, + PageScrollViewport, + StatusBadge, + useTemporalDataContext, + type DataGridColumn, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + createRecord, + fileRecordItem, + getRecord, + getRecordCatalog, + getRecordSources, + listRecords, + updateRecord, + type FilePlanNode, + type RecordCatalog, + type RecordClass, + type RecordDetail, + type RecordEntry, + type RecordSourceProvider +} from "../../api/records"; +import { + RECORDS_DOCUMENTATION, + RECORDS_FIELD_DOCUMENTATION +} from "./interfacePatterns"; + + +const EMPTY_CATALOG: RecordCatalog = { file_plan: [], classes: [] }; +const PAGE_SIZE = 50; + +export default function RecordsPage({ settings, auth }: PlatformRouteContext) { + const [searchParams, setSearchParams] = useSearchParams(); + const { selection, selectionKey, isDefault: temporalIsDefault } = useTemporalDataContext(); + const [catalog, setCatalog] = useState(EMPTY_CATALOG); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [selectedNodeId, setSelectedNodeId] = useState(""); + const [selectedRecordId, setSelectedRecordId] = useState( + () => searchParams.get("recordId") ?? "" + ); + const [detail, setDetail] = useState(null); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [error, setError] = useState(""); + const [detailError, setDetailError] = useState(""); + const [reloadKey, setReloadKey] = useState(0); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [fileOpen, setFileOpen] = useState(false); + const canWrite = auth.scopes.includes("records:workspace:write") || + auth.scopes.includes("records:workspace:admin"); + + const reload = useCallback(() => setReloadKey((value) => value + 1), []); + + useEffect(() => { + const controller = new AbortController(); + setError(""); + Promise.all([ + getRecordCatalog(settings, controller.signal), + canWrite + ? getRecordSources(settings, controller.signal) + : Promise.resolve({ providers: [] as RecordSourceProvider[] }) + ]). + then(([nextCatalog, sourceResult]) => { + setCatalog(nextCatalog); + setSources(sourceResult.providers); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(errorMessage(reason, "The Records catalog could not be loaded.")); + } + }); + return () => controller.abort(); + }, [canWrite, reloadKey, selectionKey, settings]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + listRecords(settings, { + query: submittedQuery, + filePlanNodeId: selectedNodeId || undefined, + offset: (page - 1) * PAGE_SIZE, + limit: PAGE_SIZE + }, controller.signal). + then((result) => { + setRecords(result.records); + setTotal(result.total); + const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE)); + if (page > lastPage) setPage(lastPage); + setSelectedRecordId((current) => { + if (current && result.records.some((item) => item.record_id === current)) return current; + return result.records[0]?.record_id ?? ""; + }); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(errorMessage(reason, "Records could not be loaded.")); + } + }). + finally(() => setLoading(false)); + return () => controller.abort(); + }, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]); + + useEffect(() => { + if (!selectedRecordId) { + setDetail(null); + setDetailError(""); + return undefined; + } + const controller = new AbortController(); + setDetailLoading(true); + setDetailError(""); + getRecord(settings, selectedRecordId, controller.signal). + then(setDetail). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setDetail(null); + setDetailError(errorMessage(reason, "The record could not be loaded.")); + } + }). + finally(() => setDetailLoading(false)); + return () => controller.abort(); + }, [reloadKey, selectedRecordId, selectionKey, settings]); + + useEffect(() => { + const current = searchParams.get("recordId") ?? ""; + if (current === selectedRecordId) return; + const next = new URLSearchParams(searchParams); + if (selectedRecordId) next.set("recordId", selectedRecordId); + else next.delete("recordId"); + setSearchParams(next, { replace: true }); + }, [searchParams, selectedRecordId, setSearchParams]); + + const classesById = useMemo( + () => new Map(catalog.classes.map((item) => [item.class_id, item])), + [catalog.classes] + ); + const filePlanRows = useMemo(() => orderedFilePlan(catalog.file_plan), [catalog.file_plan]); + const columns = useMemo[]>(() => [ + { + id: "number", + header: "Record number", + width: 145, + minWidth: 120, + resizable: true, + value: (row) => row.record_number, + render: (row) => {row.record_number} + }, + { + id: "title", + header: "Title", + width: "1fr", + minWidth: 180, + resizable: true, + value: (row) => row.title, + render: (row) => ( + + ) + }, + { + id: "state", + header: "State", + width: 110, + minWidth: 95, + value: (row) => row.state, + render: (row) => + } + ], [selectedRecordId]); + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setPage(1); + setSubmittedQuery(query.trim()); + } + + return ( +
+
+
+ + +
+
+ + {error && {error}} +
+ + +
+
+
+ Records + {selectedNodeId ? catalog.file_plan.find((item) => item.node_id === selectedNodeId)?.label : "All"} +
+
+
+ {loading ? ( + + ) : ( + row.record_id} + emptyText="No matching records." + initialFit="container" + resizeBehavior="cover" + pagination={{ + mode: "server", + page, + pageSize: PAGE_SIZE, + totalRows: total, + disabled: loading, + onPageChange: setPage + }} + /> + )} +
+
+ +
+ {detailLoading && } + {detailError && {detailError}} + {!detailLoading && !detailError && !detail && ( +
+
+ )} + {!detailLoading && detail && ( + 0} + onEdit={() => setEditOpen(true)} + onFile={() => setFileOpen(true)} + /> + )} +
+
+
+ + setCreateOpen(false)} + onSaved={(saved) => { + setCreateOpen(false); + setSelectedNodeId(""); + setSelectedRecordId(saved.record_id); + reload(); + }} + /> + setEditOpen(false)} + onSaved={(saved) => { + setEditOpen(false); + setSelectedRecordId(saved.record_id); + reload(); + }} + /> + setFileOpen(false)} + onSaved={() => { + setFileOpen(false); + reload(); + }} + /> +
+ ); +} + +function RecordDetailPanel({ + detail, + recordClass, + canWrite, + hasSources, + onEdit, + onFile +}: { + detail: RecordDetail; + recordClass?: RecordClass; + canWrite: boolean; + hasSources: boolean; + onEdit: () => void; + onFile: () => void; +}) { + const record = detail.record; + return ( + +
+
+ {record.record_number} +

{record.title}

+
+
+ + +
+
+ +
+
State
+
Record class{recordClass?.label ?? record.class_id}
+
Revision{record.revision}
+
Source authority{humanize(record.source_authority_mode)}
+
Valid from{formatDateTime(record.valid_from)}
+
Recorded at{formatDateTime(record.recorded_at)}
+
Classification{record.classification || "Not classified"}
+
Retention input{recordClass?.retention_period_days == null ? "Not configured" : `${recordClass.retention_period_days} days`}
+
+ + {record.description &&

{record.description}

} + +
+
+

Contents

+ {detail.items.length} +
+ {detail.items.length === 0 ? ( +

No items have been filed in this temporal view.

+ ) : ( +
+ {detail.items.map((item) => ( +
+
+ {item.launch_url ? {item.label} : {item.label}} + {item.source.source_module} · {humanize(item.source.resource_type)} · revision {item.source.source_revision} +
+
+ {formatBytes(item.size_bytes)} + +
+

{item.filing_reason}

+
+ ))} +
+ )} +
+ +
+
+

Chronology

+ {detail.chronology.length} +
+
+ {detail.chronology.map((entry) => ( +
+
+ ))} +
+
+ +
+

Access and purpose

+

{detail.access_explanation.reason}

+
+
Record purpose
{record.purpose}
+
Authorization
{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}
+
+ {detail.access_explanation.limitations.map((limitation) => ( + {limitation} + ))} +
+
+ ); +} + +function RecordDialog({ + open, + mode, + settings, + catalog, + record, + onClose, + onSaved +}: { + open: boolean; + mode: "create" | "edit"; + settings: PlatformRouteContext["settings"]; + catalog: RecordCatalog; + record?: RecordEntry | null; + onClose: () => void; + onSaved: (record: RecordEntry) => void; +}) { + const firstClass = catalog.classes.find((item) => item.active); + const [recordNumber, setRecordNumber] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [classId, setClassId] = useState(firstClass?.class_id ?? ""); + const [state, setState] = useState<"planned" | "open">("open"); + const [classification, setClassification] = useState(""); + const [purpose, setPurpose] = useState(""); + const [changeReason, setChangeReason] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + setRecordNumber(record?.record_number ?? ""); + setTitle(record?.title ?? ""); + setDescription(record?.description ?? ""); + setClassId(record?.class_id ?? firstClass?.class_id ?? ""); + setState(record?.state === "planned" ? "planned" : "open"); + setClassification(record?.classification ?? ""); + setPurpose(record?.purpose ?? ""); + setChangeReason(""); + setError(""); + }, [firstClass?.class_id, open, record]); + + const selectedClass = catalog.classes.find((item) => item.class_id === classId); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return; + setSaving(true); + setError(""); + try { + const common = { + class_id: selectedClass.class_id, + file_plan_node_id: selectedClass.file_plan_node_id, + title: title.trim(), + description: description.trim() || null, + state, + classification: classification.trim() || null, + purpose: purpose.trim(), + recorded_at: new Date().toISOString(), + change_reason: changeReason.trim(), + idempotency_key: randomId() + }; + const saved = mode === "create" + ? await createRecord(settings, { + ...common, + record_number: recordNumber.trim(), + source_authority_mode: "native_authoritative", + access_mode: "tenant", + valid_from: new Date().toISOString(), + institutional_context: {} + }) + : await updateRecord(settings, record!.record_id, { + ...common, + expected_revision: record!.revision + }); + onSaved(saved); + } catch (reason) { + setError(errorMessage(reason, "The record could not be saved.")); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > + {error && {error}} +
+ + setRecordNumber(event.target.value)} disabled={mode === "edit"} required /> + + + + + + setTitle(event.target.value)} required /> + + + + + + setClassification(event.target.value)} /> + + + setPurpose(event.target.value)} required /> + + +