feat(records): implement native eAkte vertical
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Scope
|
## 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.
|
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`.
|
- 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.
|
- 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.
|
- 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
|
## Local Workflow
|
||||||
|
|
||||||
@@ -26,5 +27,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is
|
|||||||
Focused verification:
|
Focused verification:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,9 +4,15 @@
|
|||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`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
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -29,29 +35,46 @@ Detailed boundary notes are in [docs/RECORDS_DOMAIN_BOUNDARY.md](docs/RECORDS_DO
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Expected optional integrations:
|
Implemented optional integrations:
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- dms
|
- cases
|
||||||
- docs
|
- search
|
||||||
|
|
||||||
|
Planned optional integrations include:
|
||||||
|
|
||||||
|
- forms runtime and decisions
|
||||||
|
- Campaigns, Postbox, and Reporting
|
||||||
|
- DMS/archive providers
|
||||||
- policy
|
- policy
|
||||||
- audit
|
- audit
|
||||||
- transparency
|
- 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
|
## Development Install
|
||||||
|
|
||||||
From the core checkout:
|
From the workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-records
|
||||||
./.venv/bin/python -m pip install -e ../govoplan-records
|
/mnt/DATA/git/govoplan/.venv/bin/python -m pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused manifest verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-records
|
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
|
## Gitea Workflow
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ specialist procedure. It must not duplicate all document editing or storage.
|
|||||||
Implementation is tracked in
|
Implementation is tracked in
|
||||||
[Records #1](https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/1).
|
[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
|
## Ownership Boundary
|
||||||
|
|
||||||
Records owns:
|
Records owns:
|
||||||
|
|||||||
@@ -28,22 +28,30 @@ Records management for file plans, records classification, retention schedules,
|
|||||||
- audit
|
- audit
|
||||||
- transparency
|
- transparency
|
||||||
|
|
||||||
## Seed State
|
## Implemented State
|
||||||
|
|
||||||
The current repository state is intentionally small:
|
The native kernel currently provides:
|
||||||
|
|
||||||
- module manifest and entry point
|
- versioned file-plan nodes and record classes;
|
||||||
- tenant-level permission definitions
|
- stable record identities, immutable OCC-guarded revisions, volumes, exact
|
||||||
- manager and viewer role templates
|
filed items, and chronology;
|
||||||
- documentation topic describing the module boundary
|
- independent valid and recorded time with current/at/all temporal reads;
|
||||||
- Gitea issue workflow templates
|
- tenant, purpose, actor/capacity, source authority, institutional context,
|
||||||
- manifest contract test
|
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
|
## 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,
|
The complete native/external boundary, temporal and purpose-aware record model,
|
||||||
disposition lifecycle, German public-sector provider profiles, and staged
|
disposition lifecycle, German public-sector provider profiles, and staged
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "@govoplan/records",
|
"name": "@govoplan/records",
|
||||||
"version": "0.1.18",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Records platform module seed.",
|
"description": "GovOPlaN eAkte and institutional records module.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "govoplan-records"
|
name = "govoplan-records"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
description = "GovOPlaN Records platform module seed."
|
description = "GovOPlaN eAkte and institutional records module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1,8 +1,42 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
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.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_ID = "records"
|
||||||
MODULE_NAME = "Records"
|
MODULE_NAME = "Records"
|
||||||
@@ -12,11 +46,18 @@ WRITE_SCOPE = "records:workspace:write"
|
|||||||
ADMIN_SCOPE = "records:workspace:admin"
|
ADMIN_SCOPE = "records:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"files",
|
"files",
|
||||||
|
"cases",
|
||||||
|
"forms_runtime",
|
||||||
|
"decisions",
|
||||||
|
"campaigns",
|
||||||
|
"postbox",
|
||||||
|
"reporting",
|
||||||
"dms",
|
"dms",
|
||||||
"docs",
|
"docs",
|
||||||
"policy",
|
"policy",
|
||||||
"audit",
|
"audit",
|
||||||
"transparency",
|
"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 = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View records workspace", "Read records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage records workspace", "Create and update records and workflow state."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."),
|
"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 = (
|
ROLE_TEMPLATES = (
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="records_manager",
|
slug="records_manager",
|
||||||
name="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),
|
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="records_viewer",
|
slug="records_viewer",
|
||||||
name="Records viewer",
|
name="Records viewer",
|
||||||
description="Read records and workflow context.",
|
description="Read records and their governed chronology.",
|
||||||
permissions=(READ_SCOPE,),
|
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 = (
|
DOCUMENTATION = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id="records.workspace",
|
||||||
title=f"{MODULE_NAME} module boundary",
|
title="eAkte workspace",
|
||||||
summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.",
|
summary="Create and browse institutional records, their exact filed items, and chronology.",
|
||||||
body=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"Records owns the stable record identity, file-plan classification, immutable revisions, "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"volumes, filing decisions, and chronology. Files and other source modules continue to own "
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
"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"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner"),
|
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||||
order=100,
|
order=100,
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
|
||||||
label="Repository domain boundary",
|
|
||||||
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
|
||||||
kind="repository",
|
|
||||||
),
|
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="eAkte architecture",
|
label="eAkte architecture",
|
||||||
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||||
kind="repository",
|
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={
|
metadata={
|
||||||
"seed": True,
|
"help_contexts": [
|
||||||
"domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'],
|
"records.workspace",
|
||||||
"first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.",
|
"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(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.eakte-architecture",
|
id="records.filing",
|
||||||
title="eAkte and digital record lifecycle",
|
title="Exact record filing",
|
||||||
summary="Defines native and external record operation, filing, temporal and purpose-aware access, retention, holds, appraisal, transfer, and disposition.",
|
summary="File immutable Files or Cases revisions through a provider-neutral capability.",
|
||||||
body=(
|
body=(
|
||||||
"Records owns the legal and institutional record identity, file plan, filing decisions, "
|
"Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
|
||||||
"retention and disposition lifecycle, and transfer evidence. Files owns bytes, DMS owns "
|
"revision. Records stores source identity, authority mode, digest and content metadata where "
|
||||||
"document editing, Policy owns reusable rules, and external archives remain supported "
|
"available, represented valid time, source recorded time, filing actor and capacity, and an "
|
||||||
"through explicit source-authority and provider profiles."
|
"immutable chronology entry. A repeated idempotency key replays only the identical request."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "records_manager", "operator", "module_admin", "product_owner"),
|
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||||
order=110,
|
order=110,
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=("files", "cases", "policy", "audit"),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="eAkte architecture",
|
label="Records domain boundary",
|
||||||
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||||
kind="repository",
|
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={
|
metadata={
|
||||||
"kind": "concept",
|
"help_contexts": [
|
||||||
"help_contexts": ["records.page", "records.record", "records.disposition"],
|
"records.action.file",
|
||||||
"known_limit": "The architecture is accepted, but persistence and user-visible record workflows remain a scaffold.",
|
"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(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
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,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
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,
|
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(
|
architecture=declared_module_architecture(
|
||||||
layer="content_records_evidence",
|
layer="content_records_evidence",
|
||||||
kind="domain",
|
kind="domain",
|
||||||
maturity="scaffold",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/RECORDS_DOMAIN_BOUNDARY.md",
|
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
||||||
known_limits=("Record declaration, retention, hold, transfer, and disposal are not implemented yet.",),
|
test_ref="tests/test_records.py",
|
||||||
owned_concepts=("record", "record classification", "disposition"),
|
known_limits=(
|
||||||
non_owned_concepts=("file content", "audit event", "domain object"),
|
"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",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Records Alembic migrations."""
|
||||||
+401
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Records migration revisions."""
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
File diff suppressed because it is too large
Load Diff
+34
-12
@@ -2,22 +2,44 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
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):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_records_vertical(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "records")
|
self.assertEqual("records", manifest.id)
|
||||||
self.assertEqual(manifest.name, "Records")
|
self.assertEqual("Records", manifest.name)
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(("access",), manifest.dependencies)
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
self.assertEqual(
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"records_manager", "records_viewer"})
|
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||||
self.assertTrue(manifest.documentation)
|
{permission.scope for permission in manifest.permissions},
|
||||||
self.assertIsNone(manifest.route_factory)
|
)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertEqual(
|
||||||
self.assertIsNone(manifest.frontend)
|
{"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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
institutional_context: Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<RecordDetail> {
|
||||||
|
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
|
||||||
|
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<string, unknown>): Promise<RecordEntry> {
|
||||||
|
return apiFetch(settings, "/api/v1/records", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRecord(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<RecordEntry> {
|
||||||
|
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileRecordItem(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/items`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<RecordCatalog>(EMPTY_CATALOG);
|
||||||
|
const [records, setRecords] = useState<RecordEntry[]>([]);
|
||||||
|
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<RecordDetail | null>(null);
|
||||||
|
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
|
||||||
|
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<DataGridColumn<RecordEntry>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "number",
|
||||||
|
header: "Record number",
|
||||||
|
width: 145,
|
||||||
|
minWidth: 120,
|
||||||
|
resizable: true,
|
||||||
|
value: (row) => row.record_number,
|
||||||
|
render: (row) => <span className="records-number">{row.record_number}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "title",
|
||||||
|
header: "Title",
|
||||||
|
width: "1fr",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
value: (row) => row.title,
|
||||||
|
render: (row) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`records-record-link${selectedRecordId === row.record_id ? " selected" : ""}`}
|
||||||
|
onClick={() => setSelectedRecordId(row.record_id)}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
header: "State",
|
||||||
|
width: 110,
|
||||||
|
minWidth: 95,
|
||||||
|
value: (row) => row.state,
|
||||||
|
render: (row) => <StatusBadge status={row.state === "open" ? "active" : "warning"} label={humanize(row.state)} />
|
||||||
|
}
|
||||||
|
], [selectedRecordId]);
|
||||||
|
|
||||||
|
function submitSearch(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setPage(1);
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="records-page" data-help-context-id="records.workspace">
|
||||||
|
<div className="records-shell">
|
||||||
|
<div className="records-toolbar">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
disabledReason={!canWrite ? "Your account may view records but may not create them." : catalog.classes.length === 0 ? "Configure a record class before creating a record." : undefined}
|
||||||
|
helpContextId="records.action.create"
|
||||||
|
>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
New record
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={reload} disabledReason={loading ? "Records are already loading." : undefined}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<form className="records-search" onSubmit={submitSearch}>
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
aria-label="Search records"
|
||||||
|
placeholder="Search records"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<span className="records-result-count">
|
||||||
|
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
|
||||||
|
</span>
|
||||||
|
{!temporalIsDefault && (
|
||||||
|
<StatusBadge
|
||||||
|
status="warning"
|
||||||
|
label={selection.validityMode === "all" ? "All valid-time data" : "Historical data"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DocumentationHelpLink reference={RECORDS_DOCUMENTATION} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<div className="records-workspace">
|
||||||
|
<aside className="records-file-plan" data-help-context-id="records.file-plan">
|
||||||
|
<div className="records-pane-heading">
|
||||||
|
<div>
|
||||||
|
<span>File plan</span>
|
||||||
|
<strong>{catalog.file_plan.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PageScrollViewport className="records-file-plan-scroll">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`records-plan-row${selectedNodeId === "" ? " selected" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedNodeId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Archive size={16} aria-hidden="true" />
|
||||||
|
<span>All records</span>
|
||||||
|
</button>
|
||||||
|
{filePlanRows.map(({ node, depth }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={node.node_id}
|
||||||
|
className={`records-plan-row${selectedNodeId === node.node_id ? " selected" : ""}`}
|
||||||
|
style={{ paddingInlineStart: `${12 + depth * 18}px` }}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedNodeId(node.node_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="records-plan-code">{node.code}</span>
|
||||||
|
<span>{node.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!loading && catalog.file_plan.length === 0 && (
|
||||||
|
<p className="records-empty-note">No file-plan nodes are configured.</p>
|
||||||
|
)}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="records-list-pane" data-help-context-id="records.record-list">
|
||||||
|
<div className="records-pane-heading">
|
||||||
|
<div>
|
||||||
|
<span>Records</span>
|
||||||
|
<strong>{selectedNodeId ? catalog.file_plan.find((item) => item.node_id === selectedNodeId)?.label : "All"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="records-list-grid">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingIndicator label="Loading records" />
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
id="records-workspace-list"
|
||||||
|
rows={records}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(row) => row.record_id}
|
||||||
|
emptyText="No matching records."
|
||||||
|
initialFit="container"
|
||||||
|
resizeBehavior="cover"
|
||||||
|
pagination={{
|
||||||
|
mode: "server",
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
totalRows: total,
|
||||||
|
disabled: loading,
|
||||||
|
onPageChange: setPage
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="records-detail-pane" data-help-context-id="records.record-detail">
|
||||||
|
{detailLoading && <LoadingIndicator label="Loading record" />}
|
||||||
|
{detailError && <DismissibleAlert tone="danger" resetKey={detailError}>{detailError}</DismissibleAlert>}
|
||||||
|
{!detailLoading && !detailError && !detail && (
|
||||||
|
<div className="records-detail-empty">
|
||||||
|
<Archive size={28} aria-hidden="true" />
|
||||||
|
<p>Select a record to inspect its contents and chronology.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!detailLoading && detail && (
|
||||||
|
<RecordDetailPanel
|
||||||
|
detail={detail}
|
||||||
|
recordClass={classesById.get(detail.record.class_id)}
|
||||||
|
canWrite={canWrite}
|
||||||
|
hasSources={sources.length > 0}
|
||||||
|
onEdit={() => setEditOpen(true)}
|
||||||
|
onFile={() => setFileOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecordDialog
|
||||||
|
open={createOpen}
|
||||||
|
mode="create"
|
||||||
|
settings={settings}
|
||||||
|
catalog={catalog}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setSelectedNodeId("");
|
||||||
|
setSelectedRecordId(saved.record_id);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RecordDialog
|
||||||
|
open={editOpen}
|
||||||
|
mode="edit"
|
||||||
|
settings={settings}
|
||||||
|
catalog={catalog}
|
||||||
|
record={detail?.record ?? null}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setEditOpen(false);
|
||||||
|
setSelectedRecordId(saved.record_id);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilingDialog
|
||||||
|
open={fileOpen}
|
||||||
|
settings={settings}
|
||||||
|
record={detail?.record ?? null}
|
||||||
|
providers={sources}
|
||||||
|
onClose={() => setFileOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setFileOpen(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageScrollViewport className="records-detail-scroll">
|
||||||
|
<div className="records-detail-header">
|
||||||
|
<div>
|
||||||
|
<span className="records-eyebrow">{record.record_number}</span>
|
||||||
|
<h1>{record.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="records-detail-actions">
|
||||||
|
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
|
||||||
|
<Pencil size={16} aria-hidden="true" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
|
||||||
|
<FilePlus2 size={16} aria-hidden="true" />
|
||||||
|
File item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="records-facts">
|
||||||
|
<div><span>State</span><StatusBadge status={record.state === "open" ? "active" : "warning"} label={humanize(record.state)} /></div>
|
||||||
|
<div><span>Record class</span><strong>{recordClass?.label ?? record.class_id}</strong></div>
|
||||||
|
<div><span>Revision</span><strong>{record.revision}</strong></div>
|
||||||
|
<div><span>Source authority</span><strong>{humanize(record.source_authority_mode)}</strong></div>
|
||||||
|
<div><span>Valid from</span><strong>{formatDateTime(record.valid_from)}</strong></div>
|
||||||
|
<div><span>Recorded at</span><strong>{formatDateTime(record.recorded_at)}</strong></div>
|
||||||
|
<div><span>Classification</span><strong>{record.classification || "Not classified"}</strong></div>
|
||||||
|
<div><span>Retention input</span><strong>{recordClass?.retention_period_days == null ? "Not configured" : `${recordClass.retention_period_days} days`}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{record.description && <p className="records-description">{record.description}</p>}
|
||||||
|
|
||||||
|
<section className="records-detail-section" data-help-context-id="records.record-items">
|
||||||
|
<div className="records-section-heading">
|
||||||
|
<h2>Contents</h2>
|
||||||
|
<span>{detail.items.length}</span>
|
||||||
|
</div>
|
||||||
|
{detail.items.length === 0 ? (
|
||||||
|
<p className="records-empty-note">No items have been filed in this temporal view.</p>
|
||||||
|
) : (
|
||||||
|
<div className="records-item-list">
|
||||||
|
{detail.items.map((item) => (
|
||||||
|
<div className="records-item-row" key={item.item_id}>
|
||||||
|
<div>
|
||||||
|
{item.launch_url ? <a href={item.launch_url}>{item.label}</a> : <strong>{item.label}</strong>}
|
||||||
|
<span>{item.source.source_module} · {humanize(item.source.resource_type)} · revision {item.source.source_revision}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{formatBytes(item.size_bytes)}</span>
|
||||||
|
<time>{formatDateTime(item.filed_at)}</time>
|
||||||
|
</div>
|
||||||
|
<p>{item.filing_reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="records-detail-section" data-help-context-id="records.chronology">
|
||||||
|
<div className="records-section-heading">
|
||||||
|
<h2>Chronology</h2>
|
||||||
|
<span>{detail.chronology.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="records-chronology">
|
||||||
|
{detail.chronology.map((entry) => (
|
||||||
|
<div key={entry.event_id}>
|
||||||
|
<span className="records-timeline-marker" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{entry.summary}</strong>
|
||||||
|
<span>{humanize(entry.event_type)} · {entry.purpose}</span>
|
||||||
|
</div>
|
||||||
|
<time>{formatDateTime(entry.occurred_at)}</time>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="records-detail-section records-access-explanation">
|
||||||
|
<div className="records-section-heading"><h2>Access and purpose</h2></div>
|
||||||
|
<p>{detail.access_explanation.reason}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
|
||||||
|
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{detail.access_explanation.limitations.map((limitation) => (
|
||||||
|
<DismissibleAlert key={limitation} tone="warning" dismissible={false} compact>{limitation}</DismissibleAlert>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</PageScrollViewport>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={mode === "create" ? "Create record" : "Edit record"}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={saving}
|
||||||
|
portal
|
||||||
|
className="records-dialog"
|
||||||
|
helpContextId={mode === "create" ? "records.action.create" : "records.action.edit"}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
|
||||||
|
{saving ? "Saving" : "Save record"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<form id="records-record-form" className="records-dialog-form" onSubmit={submit}>
|
||||||
|
<FormField label="Record number" helpContextId="records.field.record-number">
|
||||||
|
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="State" helpContextId="records.field.state">
|
||||||
|
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Title" helpContextId="records.field.title">
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Record class" helpContextId="records.field.class">
|
||||||
|
<select value={classId} onChange={(event) => setClassId(event.target.value)} required>
|
||||||
|
{catalog.classes.filter((item) => item.active).map((item) => <option key={item.class_id} value={item.class_id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Classification" helpContextId="records.field.classification">
|
||||||
|
<input value={classification} onChange={(event) => setClassification(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
|
||||||
|
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description" helpContextId="records.field.description">
|
||||||
|
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Change reason" helpContextId="records.field.change-reason">
|
||||||
|
<textarea value={changeReason} onChange={(event) => setChangeReason(event.target.value)} rows={3} required />
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilingDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
record,
|
||||||
|
providers,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
record: RecordEntry | null;
|
||||||
|
providers: RecordSourceProvider[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [sourceModule, setSourceModule] = useState("");
|
||||||
|
const [resourceType, setResourceType] = useState("");
|
||||||
|
const [resourceId, setResourceId] = useState("");
|
||||||
|
const [sourceRevision, setSourceRevision] = useState("");
|
||||||
|
const [purpose, setPurpose] = useState("");
|
||||||
|
const [filingReason, setFilingReason] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const provider = providers.find((item) => item.source_module === sourceModule);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const first = providers[0];
|
||||||
|
setSourceModule(first?.source_module ?? "");
|
||||||
|
setResourceType(first?.resource_types[0] ?? "");
|
||||||
|
setResourceId("");
|
||||||
|
setSourceRevision("");
|
||||||
|
setPurpose(record?.purpose ?? "");
|
||||||
|
setFilingReason("");
|
||||||
|
setError("");
|
||||||
|
}, [open, providers, record?.purpose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider?.resource_types.includes(resourceType)) return;
|
||||||
|
setResourceType(provider?.resource_types[0] ?? "");
|
||||||
|
}, [provider, resourceType]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!record || !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await fileRecordItem(settings, record.record_id, {
|
||||||
|
source: {
|
||||||
|
source_module: sourceModule,
|
||||||
|
resource_type: resourceType,
|
||||||
|
resource_id: resourceId.trim(),
|
||||||
|
source_revision: sourceRevision.trim(),
|
||||||
|
metadata: {}
|
||||||
|
},
|
||||||
|
purpose: purpose.trim(),
|
||||||
|
filing_reason: filingReason.trim(),
|
||||||
|
relationship: "contains",
|
||||||
|
idempotency_key: randomId(),
|
||||||
|
institutional_context: record.institutional_context,
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(errorMessage(reason, "The source revision could not be filed."));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="File exact source revision"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={saving}
|
||||||
|
portal
|
||||||
|
className="records-dialog"
|
||||||
|
helpContextId="records.action.file"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" form="records-filing-form" variant="primary" disabledReason={saving ? "The item is being filed." : !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim() ? "Complete the exact source and filing reason." : undefined}>
|
||||||
|
{saving ? "Filing" : "File item"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<DismissibleAlert tone="info" dismissible={false} compact>
|
||||||
|
The source module verifies your current access and resolves this exact revision before Records stores the reference.
|
||||||
|
</DismissibleAlert>
|
||||||
|
<form id="records-filing-form" className="records-dialog-form" onSubmit={submit}>
|
||||||
|
<FormField label="Source module" helpContextId="records.field.source-module">
|
||||||
|
<select value={sourceModule} onChange={(event) => setSourceModule(event.target.value)} required>
|
||||||
|
{providers.map((item) => <option key={item.source_module} value={item.source_module}>{humanize(item.source_module)}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Source type" helpContextId="records.field.source-object">
|
||||||
|
<select value={resourceType} onChange={(event) => setResourceType(event.target.value)} required>
|
||||||
|
{(provider?.resource_types ?? []).map((item) => <option key={item} value={item}>{humanize(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Source object ID" helpContextId="records.field.source-object">
|
||||||
|
<input value={resourceId} onChange={(event) => setResourceId(event.target.value)} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
|
||||||
|
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
|
||||||
|
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Filing reason" documentation={RECORDS_FIELD_DOCUMENTATION.filingReason}>
|
||||||
|
<textarea value={filingReason} onChange={(event) => setFilingReason(event.target.value)} rows={4} required />
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedFilePlan(nodes: FilePlanNode[]): Array<{ node: FilePlanNode; depth: number }> {
|
||||||
|
const children = new Map<string, FilePlanNode[]>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
const parent = node.parent_node_id ?? "";
|
||||||
|
children.set(parent, [...(children.get(parent) ?? []), node]);
|
||||||
|
}
|
||||||
|
for (const values of children.values()) values.sort((left, right) => left.code.localeCompare(right.code));
|
||||||
|
const result: Array<{ node: FilePlanNode; depth: number }> = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
function visit(parent: string, depth: number) {
|
||||||
|
for (const node of children.get(parent) ?? []) {
|
||||||
|
if (visited.has(node.node_id)) continue;
|
||||||
|
visited.add(node.node_id);
|
||||||
|
result.push({ node, depth });
|
||||||
|
visit(node.node_id, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit("", 0);
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!visited.has(node.node_id)) result.push({ node, depth: 0 });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null): string {
|
||||||
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value?: number | null): string {
|
||||||
|
if (value == null) return "Size unavailable";
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId(): string {
|
||||||
|
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(reason: unknown, fallback: string): string {
|
||||||
|
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const RECORDS_DOCUMENTATION = {
|
||||||
|
topicId: "records.workspace",
|
||||||
|
contextId: "records.workspace",
|
||||||
|
documentationType: "user" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RECORDS_FIELD_DOCUMENTATION = {
|
||||||
|
purpose: {
|
||||||
|
topicId: "records.filing",
|
||||||
|
contextId: "records.field.purpose",
|
||||||
|
documentationType: "user" as const
|
||||||
|
},
|
||||||
|
filingReason: {
|
||||||
|
topicId: "records.filing",
|
||||||
|
contextId: "records.field.filing-reason",
|
||||||
|
documentationType: "user" as const
|
||||||
|
},
|
||||||
|
sourceRevision: {
|
||||||
|
topicId: "records.filing",
|
||||||
|
contextId: "records.field.source-revision",
|
||||||
|
documentationType: "user" as const
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-records.records": "Records",
|
||||||
|
"i18n:govoplan-records.navigation": "Records navigation",
|
||||||
|
"i18n:govoplan-records.workspace": "eAkte workspace",
|
||||||
|
"i18n:govoplan-records.file_plan": "File plan",
|
||||||
|
"i18n:govoplan-records.record_list": "Record list",
|
||||||
|
"i18n:govoplan-records.record_detail": "Record detail",
|
||||||
|
"i18n:govoplan-records.file_source": "File source revision",
|
||||||
|
"i18n:govoplan-records.record_count": "{value0} records",
|
||||||
|
"Records": "Records",
|
||||||
|
"New record": "New record",
|
||||||
|
"Refresh": "Refresh",
|
||||||
|
"Search records": "Search records",
|
||||||
|
"All valid-time data": "All valid-time data",
|
||||||
|
"Historical data": "Historical data",
|
||||||
|
"File plan": "File plan",
|
||||||
|
"All records": "All records",
|
||||||
|
"All": "All",
|
||||||
|
"Record number": "Record number",
|
||||||
|
"Title": "Title",
|
||||||
|
"State": "State",
|
||||||
|
"No matching records.": "No matching records.",
|
||||||
|
"The Records catalog could not be loaded.": "The Records catalog could not be loaded.",
|
||||||
|
"No file-plan nodes are configured.": "No file-plan nodes are configured.",
|
||||||
|
"Loading records": "Loading records",
|
||||||
|
"Loading record": "Loading record",
|
||||||
|
"Select a record to inspect its contents and chronology.": "Select a record to inspect its contents and chronology.",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"File item": "File item",
|
||||||
|
"Record class": "Record class",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Source authority": "Source authority",
|
||||||
|
"Valid from": "Valid from",
|
||||||
|
"Recorded at": "Recorded at",
|
||||||
|
"Classification": "Classification",
|
||||||
|
"Retention input": "Retention input",
|
||||||
|
"Not classified": "Not classified",
|
||||||
|
"Not configured": "Not configured",
|
||||||
|
"Contents": "Contents",
|
||||||
|
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
|
||||||
|
"Chronology": "Chronology",
|
||||||
|
"Access and purpose": "Access and purpose",
|
||||||
|
"Record purpose": "Record purpose",
|
||||||
|
"Authorization": "Authorization",
|
||||||
|
"Current authorization applied": "Current authorization applied",
|
||||||
|
"Not evaluated": "Not evaluated",
|
||||||
|
"Create record": "Create record",
|
||||||
|
"Edit record": "Edit record",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Saving": "Saving",
|
||||||
|
"Save record": "Save record",
|
||||||
|
"Planned": "Planned",
|
||||||
|
"Open": "Open",
|
||||||
|
"Purpose": "Purpose",
|
||||||
|
"Description": "Description",
|
||||||
|
"Change reason": "Change reason",
|
||||||
|
"File exact source revision": "File exact source revision",
|
||||||
|
"Filing": "Filing",
|
||||||
|
"Source module": "Source module",
|
||||||
|
"Source type": "Source type",
|
||||||
|
"Source object ID": "Source object ID",
|
||||||
|
"Exact source revision": "Exact source revision",
|
||||||
|
"Filing reason": "Filing reason",
|
||||||
|
"Not set": "Not set",
|
||||||
|
"Size unavailable": "Size unavailable",
|
||||||
|
"Your account may view records but may not create them.": "Your account may view records but may not create them.",
|
||||||
|
"Configure a record class before creating a record.": "Configure a record class before creating a record.",
|
||||||
|
"Records are already loading.": "Records are already loading.",
|
||||||
|
"Your account may not revise records.": "Your account may not revise records.",
|
||||||
|
"Your account may not file record items.": "Your account may not file record items.",
|
||||||
|
"No enabled source module provides exact record references.": "No enabled source module provides exact record references.",
|
||||||
|
"The record is being saved.": "The record is being saved.",
|
||||||
|
"Complete all required record fields.": "Complete all required record fields.",
|
||||||
|
"The item is being filed.": "The item is being filed.",
|
||||||
|
"Complete the exact source and filing reason.": "Complete the exact source and filing reason.",
|
||||||
|
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference."
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-records.records": "Akten",
|
||||||
|
"i18n:govoplan-records.navigation": "Aktennavigation",
|
||||||
|
"i18n:govoplan-records.workspace": "eAkte-Arbeitsbereich",
|
||||||
|
"i18n:govoplan-records.file_plan": "Aktenplan",
|
||||||
|
"i18n:govoplan-records.record_list": "Aktenliste",
|
||||||
|
"i18n:govoplan-records.record_detail": "Aktendetails",
|
||||||
|
"i18n:govoplan-records.file_source": "Quellrevision verakten",
|
||||||
|
"i18n:govoplan-records.record_count": "{value0} Akten",
|
||||||
|
"Records": "Akten",
|
||||||
|
"New record": "Neue Akte",
|
||||||
|
"Refresh": "Aktualisieren",
|
||||||
|
"Search records": "Akten durchsuchen",
|
||||||
|
"All valid-time data": "Alle Gültigkeitszeiträume",
|
||||||
|
"Historical data": "Historische Daten",
|
||||||
|
"File plan": "Aktenplan",
|
||||||
|
"All records": "Alle Akten",
|
||||||
|
"All": "Alle",
|
||||||
|
"Record number": "Aktenzeichen",
|
||||||
|
"Title": "Titel",
|
||||||
|
"State": "Status",
|
||||||
|
"No matching records.": "Keine passenden Akten.",
|
||||||
|
"The Records catalog could not be loaded.": "Der Aktenkatalog konnte nicht geladen werden.",
|
||||||
|
"No file-plan nodes are configured.": "Es sind keine Aktenplanpositionen konfiguriert.",
|
||||||
|
"Loading records": "Akten werden geladen",
|
||||||
|
"Loading record": "Akte wird geladen",
|
||||||
|
"Select a record to inspect its contents and chronology.": "Wählen Sie eine Akte aus, um Inhalt und Chronologie einzusehen.",
|
||||||
|
"Edit": "Bearbeiten",
|
||||||
|
"File item": "Objekt verakten",
|
||||||
|
"Record class": "Aktenklasse",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Source authority": "Quellautorität",
|
||||||
|
"Valid from": "Gültig ab",
|
||||||
|
"Recorded at": "Erfasst am",
|
||||||
|
"Classification": "Klassifikation",
|
||||||
|
"Retention input": "Aufbewahrungsvorgabe",
|
||||||
|
"Not classified": "Nicht klassifiziert",
|
||||||
|
"Not configured": "Nicht konfiguriert",
|
||||||
|
"Contents": "Inhalt",
|
||||||
|
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
|
||||||
|
"Chronology": "Chronologie",
|
||||||
|
"Access and purpose": "Zugriff und Zweck",
|
||||||
|
"Record purpose": "Aktenzweck",
|
||||||
|
"Authorization": "Berechtigung",
|
||||||
|
"Current authorization applied": "Aktuelle Berechtigung angewendet",
|
||||||
|
"Not evaluated": "Nicht geprüft",
|
||||||
|
"Create record": "Akte anlegen",
|
||||||
|
"Edit record": "Akte bearbeiten",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Saving": "Speichert",
|
||||||
|
"Save record": "Akte speichern",
|
||||||
|
"Planned": "Geplant",
|
||||||
|
"Open": "Offen",
|
||||||
|
"Purpose": "Zweck",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Change reason": "Änderungsbegründung",
|
||||||
|
"File exact source revision": "Exakte Quellrevision verakten",
|
||||||
|
"Filing": "Veraktet",
|
||||||
|
"Source module": "Quellmodul",
|
||||||
|
"Source type": "Quelltyp",
|
||||||
|
"Source object ID": "ID des Quellobjekts",
|
||||||
|
"Exact source revision": "Exakte Quellrevision",
|
||||||
|
"Filing reason": "Veraktungsbegründung",
|
||||||
|
"Not set": "Nicht gesetzt",
|
||||||
|
"Size unavailable": "Größe nicht verfügbar",
|
||||||
|
"Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.",
|
||||||
|
"Configure a record class before creating a record.": "Konfigurieren Sie eine Aktenklasse, bevor Sie eine Akte anlegen.",
|
||||||
|
"Records are already loading.": "Akten werden bereits geladen.",
|
||||||
|
"Your account may not revise records.": "Ihr Konto darf Akten nicht ändern.",
|
||||||
|
"Your account may not file record items.": "Ihr Konto darf keine Objekte verakten.",
|
||||||
|
"No enabled source module provides exact record references.": "Kein aktiviertes Quellmodul stellt exakte Aktenreferenzen bereit.",
|
||||||
|
"The record is being saved.": "Die Akte wird gespeichert.",
|
||||||
|
"Complete all required record fields.": "Füllen Sie alle erforderlichen Aktenfelder aus.",
|
||||||
|
"The item is being filed.": "Das Objekt wird veraktet.",
|
||||||
|
"Complete the exact source and filing reason.": "Vervollständigen Sie die exakte Quelle und die Veraktungsbegründung.",
|
||||||
|
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert."
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, recordsModule } from "./module";
|
||||||
|
export * from "./api/records";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/records.css";
|
||||||
|
|
||||||
|
|
||||||
|
const RecordsPage = lazy(() => import("./features/records/RecordsPage"));
|
||||||
|
|
||||||
|
export const recordsModule: PlatformWebModule = {
|
||||||
|
id: "records",
|
||||||
|
label: "i18n:govoplan-records.records",
|
||||||
|
version: "0.1.18",
|
||||||
|
optionalDependencies: [
|
||||||
|
"files",
|
||||||
|
"cases",
|
||||||
|
"forms_runtime",
|
||||||
|
"decisions",
|
||||||
|
"campaigns",
|
||||||
|
"postbox",
|
||||||
|
"reporting",
|
||||||
|
"dms",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
translations: generatedTranslations,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/records",
|
||||||
|
anyOf: ["records:workspace:read"],
|
||||||
|
order: 47,
|
||||||
|
surfaceId: "records.workspace",
|
||||||
|
render: (context) => createElement(RecordsPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/records",
|
||||||
|
label: "i18n:govoplan-records.records",
|
||||||
|
iconName: "archive",
|
||||||
|
anyOf: ["records:workspace:read"],
|
||||||
|
order: 47,
|
||||||
|
surfaceId: "records.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "records.workspace.file-plan", moduleId: "records", kind: "section", label: "i18n:govoplan-records.file_plan", parentId: "records.workspace", order: 10 },
|
||||||
|
{ id: "records.workspace.list", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_list", parentId: "records.workspace", order: 20 },
|
||||||
|
{ id: "records.workspace.detail", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_detail", parentId: "records.workspace", order: 30 },
|
||||||
|
{ id: "records.workspace.file", moduleId: "records", kind: "action", label: "i18n:govoplan-records.file_source", parentId: "records.workspace.detail", order: 40 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default recordsModule;
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
.records-page,
|
||||||
|
.records-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: var(--border-line-dark);
|
||||||
|
background: var(--panel-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-search input {
|
||||||
|
min-width: 130px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-result-count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-shell > .alert {
|
||||||
|
margin: 10px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 260px) minmax(360px, 0.8fr) minmax(420px, 1.25fr);
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan,
|
||||||
|
.records-list-pane,
|
||||||
|
.records-detail-pane {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan,
|
||||||
|
.records-list-pane {
|
||||||
|
border-right: var(--border-line-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan,
|
||||||
|
.records-list-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-pane-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-pane-heading > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-pane-heading span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-pane-heading strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan-scroll,
|
||||||
|
.records-detail-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan-scroll {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-plan-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 7px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-plan-row:hover,
|
||||||
|
.records-plan-row:focus-visible,
|
||||||
|
.records-plan-row.selected {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--text-strong);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-plan-row.selected {
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-plan-row > span:last-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-plan-code {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-list-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-list-grid > .data-grid-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-record-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-record-link:hover,
|
||||||
|
.records-record-link:focus-visible,
|
||||||
|
.records-record-link.selected {
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-scroll {
|
||||||
|
padding: 18px 20px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-header,
|
||||||
|
.records-detail-actions,
|
||||||
|
.records-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: var(--border-line-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-header h1 {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 16px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 64px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts span:not(.status-badge),
|
||||||
|
.records-item-row span,
|
||||||
|
.records-item-row time,
|
||||||
|
.records-chronology span,
|
||||||
|
.records-chronology time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-description {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 34px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section-heading > span {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-list {
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 5px 18px;
|
||||||
|
padding: 11px 5px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row > div:nth-child(2) {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row a,
|
||||||
|
.records-item-row strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-row p {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-chronology > div {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-chronology > div:not(:last-child)::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 26px;
|
||||||
|
bottom: -10px;
|
||||||
|
left: 11px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--line-dark);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-timeline-marker {
|
||||||
|
z-index: 1;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
align-self: start;
|
||||||
|
margin: 5px 0 0 3px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-chronology > div > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-chronology time {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-access-explanation > p {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-access-explanation dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-access-explanation dl > div {
|
||||||
|
padding: 10px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-access-explanation dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-access-explanation dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-empty-note {
|
||||||
|
margin: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-dialog {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-dialog-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-dialog-form > .form-field:has(textarea) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1260px) {
|
||||||
|
.records-workspace {
|
||||||
|
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.records-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-search {
|
||||||
|
order: 5;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-workspace {
|
||||||
|
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-pane {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 380px;
|
||||||
|
border-top: var(--border-line-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.records-workspace {
|
||||||
|
display: flex;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-file-plan,
|
||||||
|
.records-list-pane,
|
||||||
|
.records-detail-pane {
|
||||||
|
min-height: 330px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-detail-header,
|
||||||
|
.records-detail-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-facts,
|
||||||
|
.records-access-explanation dl,
|
||||||
|
.records-dialog-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-dialog-form > .form-field:has(textarea) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user