Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0363e9f589 | ||
|
|
011c3880c8 | ||
|
|
df51bb1787 | ||
|
|
f316523a1d | ||
|
|
59860f7ac0 | ||
|
|
100170cea2 | ||
|
|
9ef79928b3 | ||
|
|
4d5c5c63ad | ||
|
|
52311178da | ||
|
|
0263f01159 | ||
|
|
226b0fff0e | ||
|
|
fd4fdf3373 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -1,8 +1,14 @@
|
||||
# GovOPlaN Records Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Records internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -11,7 +17,8 @@ Records management for file plans, records classification, retention schedules,
|
||||
- Depend on kernel contracts from `govoplan-core` and access contracts from `govoplan-access`.
|
||||
- Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics.
|
||||
- Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior.
|
||||
- Keep the seed non-invasive until runtime routes, persistence, and WebUI flows are intentionally designed.
|
||||
- Preserve exact source revisions and current source authorization at every filing boundary.
|
||||
- Keep lifecycle and archive effects explicit, idempotent, recoverable, and separately governed.
|
||||
|
||||
## Local Workflow
|
||||
|
||||
@@ -20,5 +27,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is
|
||||
Focused verification:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
**Repository type:** module (domain).
|
||||
<!-- 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
|
||||
|
||||
@@ -29,29 +35,46 @@ Detailed boundary notes are in [docs/RECORDS_DOMAIN_BOUNDARY.md](docs/RECORDS_DO
|
||||
|
||||
## Integrations
|
||||
|
||||
Expected optional integrations:
|
||||
Implemented optional integrations:
|
||||
|
||||
- files
|
||||
- dms
|
||||
- docs
|
||||
- cases
|
||||
- search
|
||||
|
||||
Planned optional integrations include:
|
||||
|
||||
- forms runtime and decisions
|
||||
- Campaigns, Postbox, and Reporting
|
||||
- DMS/archive providers
|
||||
- policy
|
||||
- audit
|
||||
- transparency
|
||||
|
||||
## Current Boundary
|
||||
|
||||
The current kernel supports planned and open records. Restricted per-record
|
||||
grants, closure, retention calculation, holds, appraisal, disposition,
|
||||
transfer, destruction, and external archive effects remain explicit later
|
||||
work packages. Enabling Records does not trigger destructive or external
|
||||
effects.
|
||||
|
||||
The detailed target and implementation sequence are documented in
|
||||
[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md).
|
||||
|
||||
## Development Install
|
||||
|
||||
From the core checkout:
|
||||
From the workspace:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m pip install -e ../govoplan-records
|
||||
cd /mnt/DATA/git/govoplan-records
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m pip install -e .
|
||||
```
|
||||
|
||||
Focused manifest verification:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-records
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
## Gitea Workflow
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# eAkte Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
The eAkte is the authoritative institutional record context for a matter,
|
||||
procedure, subject, project, or responsibility. It answers what belongs to the
|
||||
record, how it is structured, why an item was filed, which version was known,
|
||||
who may use it for which purpose, when it closes, what must be retained, and
|
||||
how it is offered, transferred, preserved, or disposed.
|
||||
|
||||
GovOPlaN Records should provide this governance lifecycle natively while being
|
||||
able to place it over an external DMS, records system, long-term archive, or
|
||||
specialist procedure. It must not duplicate all document editing or storage.
|
||||
|
||||
Implementation is tracked in
|
||||
[Records #1](https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/1).
|
||||
|
||||
## Implementation Status
|
||||
|
||||
The native foundation and governed lifecycle are implemented through the work
|
||||
packages tracked by Records #2-#6:
|
||||
|
||||
- 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,
|
||||
Cases-revision, Forms-submission, and formal-Decision providers;
|
||||
- governed close/reopen transitions, class-bound retention calculation,
|
||||
effective-dated holds, appraisal, evidence-bound disposition proposals, and
|
||||
mandatory independent approval before finalization;
|
||||
- archive-neutral transfer manifests and bounded receipts, plus an explicitly
|
||||
non-conformant simulation provider that never claims custody;
|
||||
- durable recovery-ledger fences for every API write, atomic domain/checkpoint
|
||||
commits, Audit events, source-revision revalidation, transfer-manifest
|
||||
checksum checks, and an operator recovery-evidence view;
|
||||
- tenant administration for versioned file-plan nodes and record classes,
|
||||
explicit volume management, and lifecycle controls in the eAkte workspace.
|
||||
|
||||
The remaining boundary is intentionally visible rather than implied: Records
|
||||
#7 requires selection and target testing of an archive/xdomea endpoint and
|
||||
conformance profile; #8 completes the cross-module reference journey and
|
||||
production evidence. Restricted per-record access grants also remain a
|
||||
dedicated access-policy slice. Destruction is represented only as an approved
|
||||
pending state; no content deletion or real archive effect is currently
|
||||
claimed.
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
Records owns:
|
||||
|
||||
- file plans and record classes;
|
||||
- record, volume, process/file, and record-item identity;
|
||||
- classification, filing decision, ordering, and relationship to a case or
|
||||
other institutional context;
|
||||
- effective retention rule application, closure, hold, appraisal,
|
||||
disposition proposal, approval, transfer, and destruction evidence;
|
||||
- authoritative record metadata and exact content/reference manifests;
|
||||
- external records-system mappings and source-authority mode;
|
||||
- export, transfer, archive-offer, and custody receipts.
|
||||
|
||||
Records does not own:
|
||||
|
||||
- file bytes, versions, previews, or malware handling (Files);
|
||||
- collaborative document editing, check-in/check-out, document review, or DMS
|
||||
provider behavior (DMS and connector owner);
|
||||
- generated document definitions (Templates);
|
||||
- case lifecycle (Cases), work coordination (Workflow Engine/Tasks), formal
|
||||
outcomes (Decisions), or general audit events (Audit);
|
||||
- generic retention and access policy authoring (Policy);
|
||||
- archive preservation implementation or evidence-renewal cryptography
|
||||
(external archive/TR-ESOR provider and Encryption/Identity Trust).
|
||||
|
||||
## Core Object Model
|
||||
|
||||
| Object | Meaning |
|
||||
| --- | --- |
|
||||
| File plan | Versioned hierarchy derived from institutional responsibilities |
|
||||
| Record class | Rules for required metadata, allowed content, access, retention, closure, and disposition |
|
||||
| Record | Stable legal/institutional record identity and context |
|
||||
| Volume/part | Bounded subdivision for size, period, classification, or custody |
|
||||
| Process/file | Optional business transaction grouping inside a record |
|
||||
| Record item | Immutable filing event linking exact content or an external object revision |
|
||||
| Filing note | Reason, source, relationship, ordering, actor/capacity, and evidence for inclusion |
|
||||
| Hold | Effective-dated suspension of disposition with authority and scope |
|
||||
| Appraisal | Archive value/offer decision and responsible archive interaction |
|
||||
| Disposition case | Proposed retain, transfer, destroy, or reclassify action with review and evidence |
|
||||
| Transfer package | Exact metadata/content manifest, profile, digest, encryption, and receipts |
|
||||
| Custody event | Handoff, acceptance, rejection, correction, return, or destruction observation |
|
||||
|
||||
Every object carries tenant/institution, valid and recorded time, revision,
|
||||
source authority, classification, purpose constraints, retention/hold state,
|
||||
institutional context, provenance, and optimistic-concurrency token.
|
||||
|
||||
## Record Lifecycle
|
||||
|
||||
```text
|
||||
planned -> open -> closed -> retention_running -> appraisal_due
|
||||
| |
|
||||
v v
|
||||
held offered_to_archive
|
||||
|
|
||||
+-----------------------------+------------------+
|
||||
v v v
|
||||
accepted rejected retained
|
||||
| | |
|
||||
v v v
|
||||
transferred disposal_due reappraise
|
||||
|
|
||||
v
|
||||
destroyed
|
||||
```
|
||||
|
||||
Reopening creates a governed transition and preserves the preceding retention
|
||||
schedule. A later closure only restarts retention under an explicit
|
||||
administrator action. A hold preserves reason, authority, scope, effective
|
||||
interval, policy references, and release evidence. Holds block proposal,
|
||||
approval finalization, packaging, and dispatch. A destruction approval changes
|
||||
the record to `destruction_pending`; no source object or content is deleted.
|
||||
An unapproved disposition can be withdrawn through a new immutable revision so
|
||||
a corrected proposal can supersede it. An approved disposition cannot use this
|
||||
correction path.
|
||||
|
||||
## Filing Semantics
|
||||
|
||||
- Filing links an exact immutable document/file/object revision; a later source
|
||||
revision is a new record item unless the record class permits an explicitly
|
||||
tracked living reference.
|
||||
- The item records valid time of the represented fact and recorded time of
|
||||
filing independently.
|
||||
- The current security context always controls browsing, including historical
|
||||
views.
|
||||
- Purpose-aware access may be narrower than ordinary read permission and can
|
||||
require case assignment, represented function, mandate, legal basis, or
|
||||
reason-for-access capture.
|
||||
- A record item can reference a message, decision, form submission, report,
|
||||
dataset materialization, external DMS object, physical item, or paper scan;
|
||||
it is not limited to Files.
|
||||
- Corrections and replacements link items and retain what was previously part
|
||||
of the record.
|
||||
|
||||
## Native, External, And Hybrid Operation
|
||||
|
||||
| Mode | GovOPlaN behavior |
|
||||
| --- | --- |
|
||||
| Native authoritative | Records owns lifecycle and manifests; Files or object storage owns bytes |
|
||||
| External authoritative | External eAkte/DMS owns structure and lifecycle; GovOPlaN keeps governed references and provider state |
|
||||
| External mirror | GovOPlaN keeps a read/search projection and immutable evidence snapshots |
|
||||
| Governed sync | Explicit metadata/filing fields can change on both sides with revision and conflict rules |
|
||||
| Governance overlay | GovOPlaN owns case/workflow/policy/evidence around records held externally |
|
||||
| Linked reference | Only stable identity, display metadata, authority, and launch link are retained |
|
||||
|
||||
The mode is configurable by tenant, record class, provider binding, and where
|
||||
safe by field group. A migration assesses exact objects and receipts; enabling
|
||||
a connector never silently copies all records.
|
||||
|
||||
## Standards And Provider Profiles
|
||||
|
||||
The domain contract remains neutral, while German public-sector deployments
|
||||
can add profiles for:
|
||||
|
||||
- `xdomea` exchange of files, processes, documents, file plans, and
|
||||
disposition messages. The IT-Planungsrat decision defines xdomea for
|
||||
inter-authority exchange and disposition scenarios:
|
||||
<https://www.it-planungsrat.de/beschluss/beschluss-2017-39>
|
||||
- archive offering and transfer guidance from the Bundesarchiv, including
|
||||
xdomea and XAIP/LXAIP packages:
|
||||
<https://www.bundesarchiv.de/unterlagen-abgeben/aussonderung-von-unterlagen/elektronische-akten/>
|
||||
- BSI TR-03125/TR-ESOR evidence preservation and archive information packages
|
||||
where cryptographic evidentiary value must be maintained:
|
||||
<https://www.bsi.bund.de/dok/TR-03125>
|
||||
- provider-specific DMS/VBS, archive, and specialist-procedure adapters through
|
||||
the standard external-provider declaration and recovery gate.
|
||||
|
||||
A profile declares supported operations, conformance version, metadata
|
||||
mapping, content formats, evidence behavior, size limits, retries, conflicts,
|
||||
and target-tested provider. Naming a standard is not a conformance claim.
|
||||
|
||||
## UI Model
|
||||
|
||||
The normal record workspace contains:
|
||||
|
||||
- file-plan tree and saved institutional contexts;
|
||||
- record list with class, subject, responsibility, state, retention, holds,
|
||||
source, and access explanation;
|
||||
- one record surface with metadata, chronology, structured contents, related
|
||||
case/service/decision/work, access reason, and evidence;
|
||||
- filing action available from owner modules without exposing Records internals;
|
||||
- close, reopen, hold, appraisal, transfer, and disposition workflows with
|
||||
consequence preview;
|
||||
- temporal current/at/all browsing, while clearly separating valid and recorded
|
||||
time;
|
||||
- search and export that honor current access, purpose, sealed content, and
|
||||
minimization.
|
||||
|
||||
Technical provider IDs, hashes, package schemas, and source mappings remain
|
||||
available in an evidence/details view.
|
||||
|
||||
## Recovery And Scale
|
||||
|
||||
- PostgreSQL is the durable record-state authority; content uses shared object
|
||||
storage or an external provider, never node-local paths.
|
||||
- Every external filing, transfer, or destruction uses intent-before-effect,
|
||||
idempotency, durable receipts, outcome-unknown state, and reconciliation.
|
||||
- Native Records API writes use a per-resource distributed lease and the Core
|
||||
recovery ledger. Immutable revision, chronology, Audit projection, and the
|
||||
terminal checkpoint commit atomically.
|
||||
- Backup evidence binds record rows, object manifests, provider mappings,
|
||||
policy/configuration versions, and key references.
|
||||
- Restore verifies content digests, missing keys/objects, provider reachability,
|
||||
and disposition holds before reopening effects.
|
||||
- Automated evidence tests prove that terminal Records operations and their
|
||||
hash chain remain verifiable after a database backup/restore round trip. An
|
||||
outcome-unknown transfer is persisted as non-retryable and cannot advance
|
||||
custody or record disposition state.
|
||||
- Search indexes are rebuildable projections and cannot become record
|
||||
authority.
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Persist file plans, record classes, records, record items, exact references,
|
||||
chronology, permissions, temporal reads, institutional context, and search.
|
||||
2. Integrate filing from Cases, Forms Runtime, Decisions, Campaign/Postbox,
|
||||
Files, and Reporting. **Cases, Forms Runtime, Decisions, and Files are
|
||||
implemented; Campaign/Postbox and Reporting remain.**
|
||||
3. Add closure, retention calculation, holds, appraisal, and reviewed
|
||||
disposition without destructive provider effects. **Implemented.**
|
||||
4. Add native transfer packages and one target-tested xdomea/archive provider.
|
||||
Native packaging and simulation are implemented; target selection/testing
|
||||
remains external.
|
||||
5. Add destruction/recovery, TR-ESOR provider integration, migration, and
|
||||
signed reference-journey evidence.
|
||||
|
||||
The first reference package should file the digital and assisted variants of
|
||||
the same service-to-decision journey into equivalent records and prove search,
|
||||
historical reconstruction, hold, transfer, restore, and access explanation.
|
||||
@@ -28,19 +28,39 @@ Records management for file plans, records classification, retention schedules,
|
||||
- audit
|
||||
- transparency
|
||||
|
||||
## Seed State
|
||||
## Implemented State
|
||||
|
||||
The current repository state is intentionally small:
|
||||
The native kernel currently provides:
|
||||
|
||||
- module manifest and entry point
|
||||
- tenant-level permission definitions
|
||||
- manager and viewer role templates
|
||||
- documentation topic describing the module boundary
|
||||
- Gitea issue workflow templates
|
||||
- manifest contract test
|
||||
- versioned file-plan nodes and record classes;
|
||||
- stable record identities, immutable OCC-guarded revisions, volumes, exact
|
||||
filed items, and chronology;
|
||||
- independent valid and recorded time with current/at/all temporal reads;
|
||||
- tenant, purpose, actor/capacity, source authority, institutional context,
|
||||
provenance, and idempotency fields;
|
||||
- provider-neutral exact-source capabilities, initially implemented by Files
|
||||
file versions and Cases revisions;
|
||||
- tenant APIs, search projection, uninstall/retirement guards, and a Records
|
||||
workspace using shared WebUI controls.
|
||||
- immutable close/reopen, retention, hold, appraisal, and independently
|
||||
approved disposition transitions;
|
||||
- archive-neutral manifests, bounded receipts, and a clearly marked transfer
|
||||
simulation that does not claim custody;
|
||||
- Core recovery-ledger adoption, atomic terminal evidence, Audit projection,
|
||||
source and package restore diagnostics, catalog administration, volumes, and
|
||||
lifecycle UI.
|
||||
|
||||
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 remain a separate governed slice. A target-tested
|
||||
archive adapter and any destructive effect remain deliberately unimplemented;
|
||||
approved destruction is only a pending lifecycle state.
|
||||
|
||||
## First Implementation Slice
|
||||
|
||||
Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.
|
||||
Complete restricted access and one target-tested archive provider without
|
||||
moving source-module ownership into Records. Extend the executable reference
|
||||
journey across assisted service, decision, filing, hold, restore, search, and
|
||||
archive simulation before claiming production maturity.
|
||||
|
||||
The complete native/external boundary, temporal and purpose-aware record model,
|
||||
disposition lifecycle, German public-sector provider profiles, and staged
|
||||
implementation are specified in [eAkte Architecture](EAKTE_ARCHITECTURE.md).
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@govoplan/records",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Records platform module seed.",
|
||||
"description": "GovOPlaN eAkte and institutional records module.",
|
||||
"type": "module",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-records"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN Records platform module seed."
|
||||
version = "0.1.19"
|
||||
description = "GovOPlaN eAkte and institutional records module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordArchiveProviderState,
|
||||
RecordArchiveReceipt,
|
||||
RecordArchiveTransferRequest,
|
||||
RecordContractError,
|
||||
)
|
||||
|
||||
|
||||
SIMULATION_PROVIDER_ID = "simulation"
|
||||
SIMULATION_PROFILE = "govoplan-simulation-v1"
|
||||
|
||||
|
||||
class SimulatedRecordArchiveProvider:
|
||||
"""Exercise the transfer boundary without claiming archival custody."""
|
||||
|
||||
provider_id = SIMULATION_PROVIDER_ID
|
||||
|
||||
def state(self) -> RecordArchiveProviderState:
|
||||
checked_at = datetime.now(UTC)
|
||||
return RecordArchiveProviderState(
|
||||
provider_id=self.provider_id,
|
||||
label="Records transfer simulation",
|
||||
profiles=(SIMULATION_PROFILE,),
|
||||
authority_modes=("linked_reference",),
|
||||
healthy=True,
|
||||
checked_at=checked_at,
|
||||
last_success_at=checked_at,
|
||||
freshness_seconds=0,
|
||||
limitations=(
|
||||
"Simulation validates package and receipt handling but does not transfer custody.",
|
||||
"It is not an xDOMEA or archival conformance profile.",
|
||||
),
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecordArchiveTransferRequest,
|
||||
) -> RecordArchiveReceipt:
|
||||
del session, principal
|
||||
if request.package.profile != SIMULATION_PROFILE:
|
||||
raise RecordContractError(
|
||||
"The simulation provider only accepts its declared profile."
|
||||
)
|
||||
observed_at = datetime.now(UTC)
|
||||
receipt_payload = {
|
||||
"provider_id": self.provider_id,
|
||||
"package_id": request.package.package_id,
|
||||
"manifest_sha256": request.package.manifest_sha256,
|
||||
"profile": request.package.profile,
|
||||
"outcome": "accepted",
|
||||
"simulated": True,
|
||||
}
|
||||
receipt_sha256 = hashlib.sha256(
|
||||
json.dumps(
|
||||
receipt_payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return RecordArchiveReceipt(
|
||||
provider_id=self.provider_id,
|
||||
package_id=request.package.package_id,
|
||||
outcome="accepted",
|
||||
observed_at=observed_at,
|
||||
receipt_sha256=receipt_sha256,
|
||||
external_reference=f"simulation:{request.package.package_id}",
|
||||
retry_safe=True,
|
||||
simulated=True,
|
||||
metadata={
|
||||
"manifest_sha256": request.package.manifest_sha256,
|
||||
"profile": request.package.profile,
|
||||
"custody_transferred": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SIMULATION_PROFILE",
|
||||
"SIMULATION_PROVIDER_ID",
|
||||
"SimulatedRecordArchiveProvider",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from govoplan_records.backend.db.models import (
|
||||
RecordChronologyEntry,
|
||||
RecordClassRevision,
|
||||
RecordDispositionRevision,
|
||||
RecordFilePlanRevision,
|
||||
RecordHoldRevision,
|
||||
RecordIdentity,
|
||||
RecordItem,
|
||||
RecordRevision,
|
||||
RecordTransferPackageRevision,
|
||||
RecordVolumeRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordDispositionRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordHoldRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordRevision",
|
||||
"RecordTransferPackageRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
@@ -0,0 +1,601 @@
|
||||
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
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_due_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_rule: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
appraisal_state: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True, index=True
|
||||
)
|
||||
appraisal: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
class RecordHoldRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_hold_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
|
||||
),
|
||||
Index("ix_record_hold_current", "tenant_id", "hold_id", "superseded_at"),
|
||||
Index("ix_record_hold_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
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)
|
||||
hold_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_hold_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
authority: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
scope: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
effective_from: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
effective_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
released_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
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
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordDispositionRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_disposition_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"revision",
|
||||
name="uq_record_disposition_revision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_disposition_idempotency"
|
||||
),
|
||||
Index(
|
||||
"ix_record_disposition_current",
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index("ix_record_disposition_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
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)
|
||||
disposition_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_disposition_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
subject_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
subject_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
consequence_preview: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
policy_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
approval_request_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
proposed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
reviewed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
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
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordTransferPackageRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_transfer_package_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"revision",
|
||||
name="uq_record_transfer_package_revision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_transfer_package_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_record_transfer_package_current",
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index("ix_record_transfer_package_record", "tenant_id", "record_id", "status"),
|
||||
)
|
||||
|
||||
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)
|
||||
package_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
disposition_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_transfer_package_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
record_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
profile: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
manifest: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
receipt: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
external_reference: Mapped[str | None] = mapped_column(String(1500), nullable=True)
|
||||
recovery_operation_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
simulated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
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
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordHoldRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordDispositionRevision",
|
||||
"RecordRevision",
|
||||
"RecordTransferPackageRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
@@ -1,21 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.information_governance import (
|
||||
InformationGovernanceDimension,
|
||||
ModuleInformationGovernance,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.records import (
|
||||
CAPABILITY_RECORDS_FILING,
|
||||
record_archive_capability,
|
||||
)
|
||||
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
|
||||
from govoplan_records.backend.archive import (
|
||||
SIMULATION_PROVIDER_ID,
|
||||
SimulatedRecordArchiveProvider,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "records"
|
||||
MODULE_NAME = "Records"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "records:workspace:read"
|
||||
WRITE_SCOPE = "records:workspace:write"
|
||||
ADMIN_SCOPE = "records:workspace:admin"
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"files",
|
||||
"cases",
|
||||
"forms_runtime",
|
||||
"decisions",
|
||||
"campaigns",
|
||||
"postbox",
|
||||
"reporting",
|
||||
"dms",
|
||||
"docs",
|
||||
"policy",
|
||||
"approvals",
|
||||
"audit",
|
||||
"transparency",
|
||||
"search",
|
||||
)
|
||||
|
||||
|
||||
@@ -33,67 +83,522 @@ 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 _simulated_archive_provider(
|
||||
context: ModuleContext,
|
||||
) -> SimulatedRecordArchiveProvider:
|
||||
del context
|
||||
return SimulatedRecordArchiveProvider()
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
active_holds = (
|
||||
session.query(record_models.RecordHoldRevision)
|
||||
.filter(
|
||||
record_models.RecordHoldRevision.tenant_id == tenant_id,
|
||||
record_models.RecordHoldRevision.superseded_at.is_(None),
|
||||
record_models.RecordHoldRevision.status == "active",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending_dispositions = (
|
||||
session.query(record_models.RecordDispositionRevision)
|
||||
.filter(
|
||||
record_models.RecordDispositionRevision.tenant_id == tenant_id,
|
||||
record_models.RecordDispositionRevision.superseded_at.is_(None),
|
||||
record_models.RecordDispositionRevision.status.in_(
|
||||
("review_pending", "review_unavailable")
|
||||
),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {
|
||||
"records": records,
|
||||
"open_records": open_records,
|
||||
"active_holds": active_holds,
|
||||
"pending_dispositions": pending_dispositions,
|
||||
}
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View records workspace", "Read records records, configuration, and workflow context."),
|
||||
_permission(WRITE_SCOPE, "Manage records workspace", "Create and update records records and workflow state."),
|
||||
_permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View records workspace",
|
||||
"Read currently authorized records, contents, chronology, and file-plan context.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage records workspace",
|
||||
"Create and revise records, create volumes, and file exact source revisions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer records workspace",
|
||||
"Version file-plan nodes and record classes and administer Records configuration.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="records_manager",
|
||||
name="Records manager",
|
||||
description="Manage records records and workflow state.",
|
||||
description="Create, revise, structure, and file content into records.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="records_viewer",
|
||||
name="Records viewer",
|
||||
description="Read records records and workflow context.",
|
||||
description="Read records and their governed chronology.",
|
||||
permissions=(READ_SCOPE,),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="records_administrator",
|
||||
name="Records administrator",
|
||||
description="Configure file plans and record classes and manage records.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.",
|
||||
id="records.workspace",
|
||||
title="eAkte workspace",
|
||||
summary="Create and browse institutional records, their exact filed items, and chronology.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
"Records owns the stable record identity, file-plan classification, immutable revisions, "
|
||||
"volumes, filing decisions, and chronology. Files and other source modules continue to own "
|
||||
"their content. Filing resolves and preserves an exact source revision only after the source "
|
||||
"module confirms current access. The titlebar temporal selection changes valid and recorded "
|
||||
"time while current authorization always remains in force."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Repository domain boundary",
|
||||
label="eAkte architecture",
|
||||
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "eAkte-Arbeitsbereich",
|
||||
"summary": "Institutionelle Akten, exakt veraktete Objekte und die Chronologie anlegen und einsehen.",
|
||||
"body": (
|
||||
"Records verwaltet die stabile Aktenidentität, Aktenplanklassifikation, unveränderliche "
|
||||
"Revisionen, Bände, Veraktungsentscheidungen und die Chronologie. Dateien und andere "
|
||||
"Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller "
|
||||
"Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale "
|
||||
"Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung "
|
||||
"gilt stets weiter."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"records.workspace",
|
||||
"records.file-plan",
|
||||
"records.record-list",
|
||||
"records.record-detail",
|
||||
"records.record-items",
|
||||
"records.chronology",
|
||||
"records.action.create",
|
||||
"records.action.edit",
|
||||
"records.field.record-number",
|
||||
"records.field.state",
|
||||
"records.field.title",
|
||||
"records.field.class",
|
||||
"records.field.classification",
|
||||
"records.field.description",
|
||||
"records.field.change-reason",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="records.filing",
|
||||
title="Exact record filing",
|
||||
summary="File immutable Files or Cases revisions through a provider-neutral capability.",
|
||||
body=(
|
||||
"Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
|
||||
"revision. Records stores source identity, authority mode, digest and content metadata where "
|
||||
"available, represented valid time, source recorded time, filing actor and capacity, and an "
|
||||
"immutable chronology entry. A repeated idempotency key replays only the identical request."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||
order=110,
|
||||
related_modules=("files", "cases", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Records domain boundary",
|
||||
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Exakte Veraktung",
|
||||
"summary": "Unveränderliche Datei- oder Vorgangsrevisionen über eine anbieterneutrale Schnittstelle verakten.",
|
||||
"body": (
|
||||
"Jede Veraktung benötigt eine Akte, einen Zweck, eine Veraktungsbegründung, einen "
|
||||
"Idempotenzschlüssel und eine exakte Quellrevision. Records speichert Quellidentität, "
|
||||
"Autoritätsmodus, soweit verfügbar Prüfsumme und Inhaltsmetadaten, Gültigkeits- und "
|
||||
"Erfassungszeit der Quelle, handelnde Person und Funktion sowie einen unveränderlichen "
|
||||
"Chronologieeintrag. Ein wiederholter Idempotenzschlüssel gibt nur dieselbe Anfrage erneut aus."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"seed": True,
|
||||
"domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'],
|
||||
"first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.",
|
||||
"help_contexts": [
|
||||
"records.action.file",
|
||||
"records.field.source-module",
|
||||
"records.field.source-object",
|
||||
"records.field.source-revision",
|
||||
"records.field.purpose",
|
||||
"records.field.filing-reason",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="records.lifecycle",
|
||||
title="Governed records lifecycle",
|
||||
summary="Close, retain, hold, appraise, approve, and package records with durable evidence.",
|
||||
body=(
|
||||
"Closing a record applies the versioned record-class retention rule. Reopening preserves the "
|
||||
"previous schedule unless an administrator deliberately restarts it. Effective-dated holds block "
|
||||
"disposition and transfer. Appraisal selects retain, transfer, destroy, or reclassify; a disposition "
|
||||
"binds the exact evidence digest and requires independent approval through Approvals. Approval only "
|
||||
"changes lifecycle state. Destruction remains pending and transfer simulation never claims custody. "
|
||||
"Every API mutation is fenced and recorded in the Core recovery ledger."
|
||||
),
|
||||
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": "Gesteuerter Aktenlebenszyklus",
|
||||
"summary": "Akten mit dauerhaftem Nachweis abschließen, aufbewahren, sperren, bewerten, freigeben und paketieren.",
|
||||
"body": (
|
||||
"Beim Abschluss einer Akte wird die versionierte Aufbewahrungsregel der Aktenklasse angewendet. "
|
||||
"Eine Wiedereröffnung bewahrt den bisherigen Zeitplan, sofern ein Administrator ihn nicht "
|
||||
"bewusst neu startet. Gültigkeitsbezogene Sperren blockieren Aussonderung und Übergabe. Die "
|
||||
"Bewertung wählt Aufbewahrung, Übergabe, Vernichtung oder Neuklassifikation; die Aussonderung "
|
||||
"bindet den exakten Nachweis und benötigt eine unabhängige Freigabe durch Approvals. Eine "
|
||||
"Freigabe ändert nur den Lebenszyklusstatus. Vernichtung bleibt vorgemerkt, und eine Simulation "
|
||||
"behauptet keine Archivverwahrung. Jede API-Änderung wird im Recovery-Ledger abgesichert."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"records.lifecycle",
|
||||
"records.lifecycle.volume",
|
||||
"records.lifecycle.close",
|
||||
"records.lifecycle.reopen",
|
||||
"records.lifecycle.appraise",
|
||||
"records.lifecycle.hold",
|
||||
"records.lifecycle.release-hold",
|
||||
"records.lifecycle.disposition",
|
||||
"records.lifecycle.finalize",
|
||||
"records.lifecycle.prepare-transfer",
|
||||
"records.lifecycle.dispatch-transfer",
|
||||
"records.lifecycle.recovery",
|
||||
"records.catalog.admin",
|
||||
"records.field.volume",
|
||||
"records.field.volume-label",
|
||||
"records.field.retention-trigger",
|
||||
"records.field.disposition",
|
||||
"records.field.hold-authority",
|
||||
"records.field.lifecycle-reason",
|
||||
"records.field.archive-provider",
|
||||
"records.field.archive-profile",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/records",
|
||||
label="Records",
|
||||
icon="archive",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.navigation",
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/records-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/records",
|
||||
component="RecordsPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.workspace",
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/records",
|
||||
label="Records",
|
||||
icon="archive",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.navigation",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="records.workspace.file-plan",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="File plan",
|
||||
parent_id="records.workspace",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.list",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Record list",
|
||||
parent_id="records.workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.detail",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Record detail",
|
||||
parent_id="records.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.file",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="File source revision",
|
||||
parent_id="records.workspace.detail",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="records.archive", version="1.0.0"),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_RECORDS_FILING: _records_registry,
|
||||
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
|
||||
},
|
||||
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",
|
||||
),
|
||||
record_archive_capability(SIMULATION_PROVIDER_ID): CapabilityDocumentation(
|
||||
label="Record archive simulation",
|
||||
summary=(
|
||||
"Validates archive-neutral package and receipt handling without transferring custody."
|
||||
),
|
||||
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.RecordTransferPackageRevision,
|
||||
record_models.RecordDispositionRevision,
|
||||
record_models.RecordHoldRevision,
|
||||
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.RecordHoldRevision,
|
||||
record_models.RecordDispositionRevision,
|
||||
record_models.RecordTransferPackageRevision,
|
||||
record_models.RecordClassRevision,
|
||||
record_models.RecordFilePlanRevision,
|
||||
label="Records",
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="records.objects", factory=create_records_search_source
|
||||
),
|
||||
),
|
||||
documentation=DOCUMENTATION,
|
||||
information_governance=ModuleInformationGovernance(
|
||||
temporal_browsing=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_volume",
|
||||
"record_item",
|
||||
"record_class",
|
||||
"file_plan_node",
|
||||
"record_hold",
|
||||
"record_disposition",
|
||||
"record_transfer_package",
|
||||
),
|
||||
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="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_class",
|
||||
"record_hold",
|
||||
"record_disposition",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/service.py",
|
||||
"tests/test_records.py",
|
||||
),
|
||||
),
|
||||
institutional_context=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_item",
|
||||
"record_event",
|
||||
"record_class",
|
||||
"file_plan_node",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/db/models.py",
|
||||
"src/govoplan_records/backend/service.py",
|
||||
"tests/test_records.py",
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="content_records_evidence",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
||||
test_ref="tests/test_records.py",
|
||||
known_limits=(
|
||||
"Restricted object grants are tracked separately.",
|
||||
"Archive packaging is native, but real target conformance and destructive effects remain external work.",
|
||||
),
|
||||
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",
|
||||
"record hold",
|
||||
"record appraisal",
|
||||
"record disposition",
|
||||
"record transfer package",
|
||||
),
|
||||
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,291 @@
|
||||
"""Add governed Records lifecycle evidence.
|
||||
|
||||
Revision ID: 7f5b3d9a2c1e
|
||||
Revises: 6e4a2c8f1d9b
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7f5b3d9a2c1e"
|
||||
down_revision = "6e4a2c8f1d9b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("record_revisions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("retention_started_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("retention_due_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"retention_rule",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("appraisal_state", sa.String(length=40), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"appraisal",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_closed_at"), ["closed_at"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_retention_started_at"),
|
||||
["retention_started_at"],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_retention_due_at"),
|
||||
["retention_due_at"],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
op.f("ix_record_revisions_appraisal_state"),
|
||||
["appraisal_state"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_hold_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hold_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("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("authority", sa.String(length=500), nullable=False),
|
||||
sa.Column("scope", sa.JSON(), nullable=False),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("released_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
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("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_hold_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "hold_id", "revision", name="uq_record_hold_revision"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_hold_idempotency"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_hold_revisions",
|
||||
"tenant_id",
|
||||
"hold_id",
|
||||
"record_id",
|
||||
"previous_revision_id",
|
||||
"status",
|
||||
"effective_from",
|
||||
"effective_to",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_hold_current",
|
||||
"record_hold_revisions",
|
||||
["tenant_id", "hold_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_hold_record",
|
||||
"record_hold_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_disposition_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("disposition_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("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("subject_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("subject_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("consequence_preview", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("approval_request_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("proposed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), 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_disposition_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"revision",
|
||||
name="uq_record_disposition_revision",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_disposition_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_disposition_revisions",
|
||||
"tenant_id",
|
||||
"disposition_id",
|
||||
"record_id",
|
||||
"previous_revision_id",
|
||||
"action",
|
||||
"status",
|
||||
"approval_request_id",
|
||||
"proposed_by",
|
||||
"reviewed_by",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_disposition_current",
|
||||
"record_disposition_revisions",
|
||||
["tenant_id", "disposition_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_disposition_record",
|
||||
"record_disposition_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_transfer_package_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("package_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("disposition_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("record_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("provider_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("profile", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("authority_mode", sa.String(length=40), nullable=False),
|
||||
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||
sa.Column("manifest_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("receipt", sa.JSON(), nullable=False),
|
||||
sa.Column("receipt_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("external_reference", sa.String(length=1500), nullable=True),
|
||||
sa.Column("recovery_operation_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("simulated", sa.Boolean(), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
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("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_transfer_package_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"revision",
|
||||
name="uq_record_transfer_package_revision",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_record_transfer_package_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_transfer_package_revisions",
|
||||
"tenant_id",
|
||||
"package_id",
|
||||
"record_id",
|
||||
"disposition_id",
|
||||
"previous_revision_id",
|
||||
"provider_id",
|
||||
"status",
|
||||
"recovery_operation_id",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_transfer_package_current",
|
||||
"record_transfer_package_revisions",
|
||||
["tenant_id", "package_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_transfer_package_record",
|
||||
"record_transfer_package_revisions",
|
||||
["tenant_id", "record_id", "status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("record_transfer_package_revisions")
|
||||
op.drop_table("record_disposition_revisions")
|
||||
op.drop_table("record_hold_revisions")
|
||||
|
||||
with op.batch_alter_table("record_revisions") as batch_op:
|
||||
batch_op.drop_index(op.f("ix_record_revisions_appraisal_state"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_retention_due_at"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_retention_started_at"))
|
||||
batch_op.drop_index(op.f("ix_record_revisions_closed_at"))
|
||||
batch_op.drop_column("appraisal")
|
||||
batch_op.drop_column("appraisal_state")
|
||||
batch_op.drop_column("retention_rule")
|
||||
batch_op.drop_column("retention_due_at")
|
||||
batch_op.drop_column("retention_started_at")
|
||||
batch_op.drop_column("closed_at")
|
||||
|
||||
|
||||
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,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from contextvars import ContextVar, Token
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryPlan,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
|
||||
|
||||
class RecordRecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_recovery_operation_id: ContextVar[str | None] = ContextVar(
|
||||
"records_recovery_operation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
def bind_record_recovery_operation(operation_id: str) -> Token[str | None]:
|
||||
return _recovery_operation_id.set(operation_id)
|
||||
|
||||
|
||||
def current_record_recovery_operation() -> str | None:
|
||||
return _recovery_operation_id.get()
|
||||
|
||||
|
||||
def reset_record_recovery_operation(token: Token[str | None]) -> None:
|
||||
_recovery_operation_id.reset(token)
|
||||
|
||||
|
||||
def record_session_factory(session: Session) -> sessionmaker[Session]:
|
||||
bind = session.get_bind()
|
||||
if bind is None:
|
||||
raise RecordRecoveryError("Records recovery requires a bound database session.")
|
||||
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RecordAtomicRecovery:
|
||||
operation: DurableRecoveryOperation | None
|
||||
operation_id: str
|
||||
replayed: bool
|
||||
|
||||
def commit_success(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
result: object,
|
||||
resource_id: str,
|
||||
) -> None:
|
||||
evidence = {
|
||||
"verified": True,
|
||||
"resource_id": resource_id,
|
||||
"result_sha256": _canonical_sha256(result),
|
||||
"domain_and_checkpoint_atomic": True,
|
||||
"checks": {
|
||||
"domain_result_digest_recorded": True,
|
||||
"domain_and_checkpoint_atomic": True,
|
||||
},
|
||||
}
|
||||
if self.operation is None:
|
||||
session.commit()
|
||||
return
|
||||
self.operation.commit_atomic_success(session, evidence=evidence)
|
||||
|
||||
def reject(self, *, summary: str, error_type: str) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.operation.reject(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"domain_mutation_committed": False,
|
||||
"error_type": error_type,
|
||||
"checks": {"definitive_domain_rejection": True},
|
||||
},
|
||||
)
|
||||
|
||||
def fail(self, *, summary: str, error_type: str) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.operation.fail(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"domain_mutation_committed": False,
|
||||
"error_type": error_type,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def begin_record_atomic_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operation_type: str,
|
||||
idempotency_key: str,
|
||||
request: dict[str, Any],
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> RecordAtomicRecovery:
|
||||
operation_key = hashlib.sha256(
|
||||
f"{tenant_id}:{operation_type}:{idempotency_key}".encode("utf-8")
|
||||
).hexdigest()
|
||||
lease_id = hashlib.sha256(
|
||||
f"{tenant_id}:{resource_type}:{resource_id}".encode("utf-8")
|
||||
).hexdigest()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
record_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="records",
|
||||
operation_type=operation_type,
|
||||
idempotency_key=f"records:{operation_key}",
|
||||
request={"tenant_id": tenant_id, **request},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.ATOMIC,
|
||||
preconditions=(
|
||||
"the current actor is authorized for the Records mutation",
|
||||
"the expected revision and idempotency key are present",
|
||||
"the target resource has no unresolved recovery operation",
|
||||
),
|
||||
verification_steps=(
|
||||
"commit the immutable domain revision and chronology entry",
|
||||
"commit the terminal recovery checkpoint in the same transaction",
|
||||
"verify the result digest and recovery evidence chain",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"tenant_id": tenant_id,
|
||||
"resource_type": resource_type,
|
||||
"resource_id_sha256": hashlib.sha256(
|
||||
resource_id.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"request_sha256": _canonical_sha256(request),
|
||||
"external_effect": False,
|
||||
},
|
||||
lease_resource_key=f"records:{tenant_id}:{lease_id}",
|
||||
lease_ttl_seconds=5 * 60,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
metadata={
|
||||
"resources": ["postgresql"],
|
||||
"external_effect": False,
|
||||
"recovery_declaration": "records-atomic-revision",
|
||||
},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except RecoveryOperationBusy as exc:
|
||||
raise RecordRecoveryError(
|
||||
"Another runtime is changing this Records resource."
|
||||
) from exc
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
raise RecordRecoveryError(
|
||||
"This Records resource has an active or unresolved recovery operation."
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
||||
raise RecordRecoveryError(
|
||||
"The recovery ledger is unavailable; the Records mutation was not started."
|
||||
) from exc
|
||||
return RecordAtomicRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecordAtomicRecovery",
|
||||
"RecordRecoveryError",
|
||||
"begin_record_atomic_recovery",
|
||||
"bind_record_recovery_operation",
|
||||
"current_record_recovery_operation",
|
||||
"record_session_factory",
|
||||
"reset_record_recovery_operation",
|
||||
]
|
||||
@@ -0,0 +1,690 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
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.audit.logging import audit_from_principal
|
||||
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,
|
||||
RecordAppraisalRequest,
|
||||
RecordArchiveProviderResponse,
|
||||
RecordCatalogResponse,
|
||||
RecordClassWriteRequest,
|
||||
RecordCloseRequest,
|
||||
RecordCreateRequest,
|
||||
RecordDetailResponse,
|
||||
RecordDispositionCreateRequest,
|
||||
RecordDispositionFinalizeRequest,
|
||||
RecordDispositionWithdrawRequest,
|
||||
RecordHoldCreateRequest,
|
||||
RecordHoldReleaseRequest,
|
||||
RecordItemCreateRequest,
|
||||
RecordLifecycleActionRequest,
|
||||
RecordListResponse,
|
||||
RecordRecoveryStatusResponse,
|
||||
RecordSourceProviderResponse,
|
||||
RecordTransferDispatchRequest,
|
||||
RecordTransferPackageCreateRequest,
|
||||
RecordUpdateRequest,
|
||||
RecordVolumeCreateRequest,
|
||||
)
|
||||
from govoplan_records.backend.service import (
|
||||
RecordConflictError,
|
||||
RecordNotFoundError,
|
||||
RecordSourceUnavailableError,
|
||||
RecordStoreError,
|
||||
SqlRecordRegistry,
|
||||
)
|
||||
from govoplan_records.backend.recovery import (
|
||||
RecordRecoveryError,
|
||||
begin_record_atomic_recovery,
|
||||
bind_record_recovery_operation,
|
||||
reset_record_recovery_operation,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="catalog.file_plan.write",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record_file_plan_node",
|
||||
resource_id=payload.node_id,
|
||||
)
|
||||
|
||||
@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")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="catalog.class.write",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record_class",
|
||||
resource_id=payload.class_id,
|
||||
)
|
||||
|
||||
@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("/archive-providers", response_model=RecordArchiveProviderResponse)
|
||||
def api_archive_providers(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordArchiveProviderResponse:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return RecordArchiveProviderResponse(
|
||||
providers=records.archive_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")
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.create",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=payload.record_id or payload.record_number,
|
||||
)
|
||||
|
||||
@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.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse)
|
||||
def api_record_recovery_status(
|
||||
record_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordRecoveryStatusResponse:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
try:
|
||||
return RecordRecoveryStatusResponse(
|
||||
**records.recovery_status(session, principal, record_id=record_id)
|
||||
)
|
||||
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),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.revise",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json", exclude_unset=True),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/close", response_model=dict[str, Any])
|
||||
def api_close_record(
|
||||
record_id: str,
|
||||
payload: RecordCloseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
if payload.restart_retention:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.close_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.close",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/reopen", response_model=dict[str, Any])
|
||||
def api_reopen_record(
|
||||
record_id: str,
|
||||
payload: RecordLifecycleActionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.reopen_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.reopen",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post("/{record_id}/appraise", response_model=dict[str, Any])
|
||||
def api_appraise_record(
|
||||
record_id: str,
|
||||
payload: RecordAppraisalRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
if payload.override_retention_not_due:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.appraise_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="record.appraise",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/holds",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_apply_hold(
|
||||
record_id: str,
|
||||
payload: RecordHoldCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.apply_hold(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="hold.apply",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/holds/{hold_id}/release",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_release_hold(
|
||||
record_id: str,
|
||||
hold_id: str,
|
||||
payload: RecordHoldReleaseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.release_hold(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
hold_id=hold_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="hold.release",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={"hold_id": hold_id, **payload.model_dump(mode="json")},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_propose_disposition(
|
||||
record_id: str,
|
||||
payload: RecordDispositionCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.propose_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.propose",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions/{disposition_id}/finalize",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_finalize_disposition(
|
||||
record_id: str,
|
||||
disposition_id: str,
|
||||
payload: RecordDispositionFinalizeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.finalize_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
disposition_id=disposition_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.finalize",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={
|
||||
"disposition_id": disposition_id,
|
||||
**payload.model_dump(mode="json"),
|
||||
},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/dispositions/{disposition_id}/withdraw",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_withdraw_disposition(
|
||||
record_id: str,
|
||||
disposition_id: str,
|
||||
payload: RecordDispositionWithdrawRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.withdraw_disposition(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
disposition_id=disposition_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="disposition.withdraw",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={
|
||||
"disposition_id": disposition_id,
|
||||
**payload.model_dump(mode="json"),
|
||||
},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/transfer-packages",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_prepare_transfer_package(
|
||||
record_id: str,
|
||||
payload: RecordTransferPackageCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.prepare_transfer_package(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="transfer.prepare",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/transfer-packages/{package_id}/dispatch",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_dispatch_transfer_package(
|
||||
record_id: str,
|
||||
package_id: str,
|
||||
payload: RecordTransferDispatchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.dispatch_transfer_package(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
package_id=package_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="transfer.simulate",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request={"package_id": package_id, **payload.model_dump(mode="json")},
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@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"),
|
||||
),
|
||||
principal=principal,
|
||||
operation_type="volume.create",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
@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,
|
||||
principal=principal,
|
||||
operation_type="item.file",
|
||||
idempotency_key=payload.idempotency_key,
|
||||
request=payload.model_dump(mode="json"),
|
||||
resource_type="record",
|
||||
resource_id=record_id,
|
||||
)
|
||||
|
||||
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,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
operation_type: str,
|
||||
idempotency_key: str,
|
||||
request: dict[str, Any],
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
):
|
||||
recovery = None
|
||||
try:
|
||||
recovery = begin_record_atomic_recovery(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
operation_type=operation_type,
|
||||
idempotency_key=idempotency_key,
|
||||
request=request,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
recovery_token = bind_record_recovery_operation(recovery.operation_id)
|
||||
try:
|
||||
result = operation()
|
||||
finally:
|
||||
reset_record_recovery_operation(recovery_token)
|
||||
if not recovery.replayed:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=f"records.{operation_type}",
|
||||
object_type=resource_type,
|
||||
object_id=resource_id,
|
||||
details={
|
||||
"recovery_operation_id": recovery.operation_id,
|
||||
"idempotency_key_sha256": _sha256(idempotency_key),
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
recovery.commit_success(session, result=result, resource_id=resource_id)
|
||||
return result
|
||||
except (RecordStoreError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
if recovery is not None:
|
||||
recovery.reject(summary=str(exc), error_type=type(exc).__name__)
|
||||
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
|
||||
except RecordRecoveryError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
if recovery is not None:
|
||||
recovery.fail(summary=str(exc), error_type=type(exc).__name__)
|
||||
raise
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
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,310 @@
|
||||
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 RecordLifecycleActionRequest(StrictModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordCloseRequest(RecordLifecycleActionRequest):
|
||||
retention_trigger_at: datetime | None = None
|
||||
restart_retention: bool = False
|
||||
|
||||
|
||||
class RecordAppraisalRequest(RecordLifecycleActionRequest):
|
||||
outcome: Literal["retain", "transfer", "destroy", "reclassify"]
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
override_retention_not_due: bool = False
|
||||
|
||||
|
||||
class RecordHoldCreateRequest(StrictModel):
|
||||
hold_id: str | None = Field(default=None, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=10_000)
|
||||
authority: str = Field(min_length=1, max_length=500)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
scope: dict[str, Any] = Field(default_factory=dict)
|
||||
effective_from: datetime | None = None
|
||||
effective_to: datetime | None = None
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if (
|
||||
self.effective_from
|
||||
and self.effective_to
|
||||
and self.effective_to <= self.effective_from
|
||||
):
|
||||
raise ValueError("effective_to must be after effective_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordHoldReleaseRequest(StrictModel):
|
||||
expected_hold_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionCreateRequest(StrictModel):
|
||||
disposition_id: str | None = Field(default=None, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
action: Literal["retain", "transfer", "destroy", "reclassify"]
|
||||
reason: str = Field(min_length=1, max_length=10_000)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionFinalizeRequest(StrictModel):
|
||||
expected_disposition_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordDispositionWithdrawRequest(RecordDispositionFinalizeRequest):
|
||||
reason: str = Field(min_length=1, max_length=2_000)
|
||||
|
||||
|
||||
class RecordTransferPackageCreateRequest(StrictModel):
|
||||
package_id: str | None = Field(default=None, max_length=255)
|
||||
disposition_id: str = Field(min_length=1, max_length=255)
|
||||
expected_record_revision: int = Field(ge=1)
|
||||
provider_id: str = Field(min_length=1, max_length=100)
|
||||
profile: str = Field(min_length=1, max_length=255)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordTransferDispatchRequest(StrictModel):
|
||||
expected_package_revision: int = Field(ge=1)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
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]]
|
||||
holds: list[dict[str, Any]]
|
||||
dispositions: list[dict[str, Any]]
|
||||
transfer_packages: list[dict[str, Any]]
|
||||
access_explanation: dict[str, Any]
|
||||
|
||||
|
||||
class RecordSourceProviderResponse(StrictModel):
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RecordArchiveProviderResponse(StrictModel):
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RecordRecoveryStatusResponse(StrictModel):
|
||||
record_id: str
|
||||
record_revision: int
|
||||
evidence_sha256: str
|
||||
healthy: bool
|
||||
source_checks: list[dict[str, Any]]
|
||||
package_checks: list[dict[str, Any]]
|
||||
recovery_operations: list[dict[str, Any]]
|
||||
failure_count: int
|
||||
limitations: list[str]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilePlanNodeWriteRequest",
|
||||
"RecordAppraisalRequest",
|
||||
"RecordArchiveProviderResponse",
|
||||
"RecordCatalogResponse",
|
||||
"RecordClassWriteRequest",
|
||||
"RecordCloseRequest",
|
||||
"RecordCreateRequest",
|
||||
"RecordDetailResponse",
|
||||
"RecordDispositionCreateRequest",
|
||||
"RecordDispositionFinalizeRequest",
|
||||
"RecordDispositionWithdrawRequest",
|
||||
"RecordHoldCreateRequest",
|
||||
"RecordHoldReleaseRequest",
|
||||
"RecordItemCreateRequest",
|
||||
"RecordLifecycleActionRequest",
|
||||
"RecordListResponse",
|
||||
"RecordRecoveryStatusResponse",
|
||||
"RecordSourceProviderResponse",
|
||||
"RecordTransferDispatchRequest",
|
||||
"RecordTransferPackageCreateRequest",
|
||||
"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
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "assisted-service-to-decision-record",
|
||||
"description": "A completed assisted service is filed, closed, held, appraised, independently approved, and exercised through the archive simulation boundary.",
|
||||
"record": {
|
||||
"record_id": "record-1",
|
||||
"record_number": "2026/0001",
|
||||
"class_id": "class-permit"
|
||||
},
|
||||
"expected": {
|
||||
"closed_state": "retention_running",
|
||||
"appraised_state": "appraised",
|
||||
"approved_state": "transfer_pending",
|
||||
"hold_blocks_disposition": true,
|
||||
"disposition_status": "approved",
|
||||
"transfer_status": "simulated_accepted",
|
||||
"custody_transferred": false
|
||||
}
|
||||
}
|
||||
+34
-12
@@ -2,22 +2,44 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
||||
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
|
||||
from govoplan_records.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ManifestSeedTests(unittest.TestCase):
|
||||
def test_manifest_registers_seed_contract(self) -> None:
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def test_manifest_registers_records_vertical(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "records")
|
||||
self.assertEqual(manifest.name, "Records")
|
||||
self.assertEqual(manifest.dependencies, ("access",))
|
||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"records_manager", "records_viewer"})
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual("records", manifest.id)
|
||||
self.assertEqual("Records", manifest.name)
|
||||
self.assertEqual(("access",), manifest.dependencies)
|
||||
self.assertEqual(
|
||||
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||
{permission.scope for permission in manifest.permissions},
|
||||
)
|
||||
self.assertEqual(
|
||||
{"records_manager", "records_viewer", "records_administrator"},
|
||||
{role.slug for role in manifest.role_templates},
|
||||
)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertIn(CAPABILITY_RECORDS_FILING, manifest.capability_factories)
|
||||
self.assertEqual(
|
||||
"vertical_slice",
|
||||
manifest.architecture.maturity if manifest.architecture else None,
|
||||
)
|
||||
self.assertEqual(
|
||||
"enforced", manifest.information_governance.temporal_browsing.adoption
|
||||
)
|
||||
self.assertEqual(
|
||||
"partial", manifest.information_governance.purpose_aware_access.adoption
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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_hold_revisions",
|
||||
"record_identities",
|
||||
"record_items",
|
||||
"record_disposition_revisions",
|
||||
"record_revisions",
|
||||
"record_transfer_package_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(
|
||||
"7f5b3d9a2c1e",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,816 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordArchiveProviderState,
|
||||
RecordArchiveReceipt,
|
||||
RecordFilingRequest,
|
||||
RecordSourceLocator,
|
||||
RecordSourceReference,
|
||||
)
|
||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||
from govoplan_core.core.approvals import ApprovalCheck, ApprovalRequestRef
|
||||
from govoplan_core.core.temporal import (
|
||||
TemporalDataContext,
|
||||
bind_temporal_data_context,
|
||||
reset_temporal_data_context,
|
||||
)
|
||||
from govoplan_records.backend.db.models import (
|
||||
RecordChronologyEntry,
|
||||
RecordClassRevision,
|
||||
RecordDispositionRevision,
|
||||
RecordFilePlanRevision,
|
||||
RecordHoldRevision,
|
||||
RecordIdentity,
|
||||
RecordItem,
|
||||
RecordRevision,
|
||||
RecordTransferPackageRevision,
|
||||
RecordVolumeRevision,
|
||||
)
|
||||
from govoplan_records.backend.archive import SimulatedRecordArchiveProvider
|
||||
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 UnknownOutcomeArchiveProvider:
|
||||
provider_id = "unknown_simulation"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.dispatch_count = 0
|
||||
|
||||
def state(self):
|
||||
return RecordArchiveProviderState(
|
||||
provider_id=self.provider_id,
|
||||
label="Unknown-outcome simulation",
|
||||
profiles=("govoplan-unknown-outcome-v1",),
|
||||
authority_modes=("linked_reference",),
|
||||
healthy=True,
|
||||
checked_at=NOW,
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
def dispatch(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.dispatch_count += 1
|
||||
return RecordArchiveReceipt(
|
||||
provider_id=self.provider_id,
|
||||
package_id=request.package.package_id,
|
||||
outcome="outcome_unknown",
|
||||
observed_at=NOW + timedelta(minutes=12),
|
||||
receipt_sha256="b" * 64,
|
||||
retry_safe=False,
|
||||
simulated=True,
|
||||
metadata={"custody_transferred": False},
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self) -> None:
|
||||
self.provider = SourceProvider()
|
||||
self.archive_provider = SimulatedRecordArchiveProvider()
|
||||
self.unknown_archive_provider = UnknownOutcomeArchiveProvider()
|
||||
self.approvals = ApprovalProvider()
|
||||
|
||||
def capability_names(self):
|
||||
return (
|
||||
"records.source.files",
|
||||
"records.archive.simulation",
|
||||
"records.archive.unknown_simulation",
|
||||
)
|
||||
|
||||
def tenant_capability(self, name, session, *, tenant_id):
|
||||
del session
|
||||
if tenant_id != "tenant-1":
|
||||
return None
|
||||
return {
|
||||
"records.source.files": self.provider,
|
||||
"records.archive.simulation": self.archive_provider,
|
||||
"records.archive.unknown_simulation": self.unknown_archive_provider,
|
||||
"approvals.requests": self.approvals,
|
||||
}.get(name)
|
||||
|
||||
|
||||
class ApprovalProvider:
|
||||
def __init__(self) -> None:
|
||||
self.approved = False
|
||||
self.request = None
|
||||
|
||||
def create_request(self, session, principal, *, command, idempotency_key):
|
||||
del session, principal, idempotency_key
|
||||
self.request = command
|
||||
return ApprovalRequestRef(id="approval-1", revision=1, state="pending")
|
||||
|
||||
def check_approved(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
request_id,
|
||||
subject_module,
|
||||
subject_type,
|
||||
subject_id,
|
||||
subject_version,
|
||||
subject_digest,
|
||||
):
|
||||
del session, principal
|
||||
return ApprovalCheck(
|
||||
request_id=request_id,
|
||||
revision=2 if self.approved else 1,
|
||||
state="approved" if self.approved else "pending",
|
||||
approved=self.approved,
|
||||
subject_module=subject_module,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
subject_version=subject_version,
|
||||
subject_digest=subject_digest,
|
||||
)
|
||||
|
||||
def create_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revise_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def publish_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def decide(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
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__,
|
||||
RecordHoldRevision.__table__,
|
||||
RecordDispositionRevision.__table__,
|
||||
RecordTransferPackageRevision.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__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),
|
||||
)
|
||||
|
||||
def test_governed_lifecycle_hold_disposition_and_transfer_simulation(self) -> None:
|
||||
fixture = json.loads(
|
||||
(
|
||||
Path(__file__).parent / "fixtures/service_to_decision_journey.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
expected = fixture["expected"]
|
||||
self._create_record()
|
||||
closed = self.records.close_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 1,
|
||||
"purpose": "close completed case record",
|
||||
"reason": "The administrative decision is final.",
|
||||
"recorded_at": NOW + timedelta(minutes=2),
|
||||
"idempotency_key": "record-close-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["closed_state"], closed["state"])
|
||||
self.assertEqual(
|
||||
NOW + timedelta(minutes=2, days=3650),
|
||||
datetime.fromisoformat(str(closed["retention_due_at"])),
|
||||
)
|
||||
appraised = self.records.appraise_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 2,
|
||||
"outcome": "transfer",
|
||||
"purpose": "appraise completed record",
|
||||
"reason": "Transfer to the institutional archive after review.",
|
||||
"policy_refs": ["records-policy:v1"],
|
||||
"override_retention_not_due": True,
|
||||
"recorded_at": NOW + timedelta(minutes=3),
|
||||
"idempotency_key": "record-appraise-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["appraised_state"], appraised["state"])
|
||||
hold = self.records.apply_hold(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"reason": "Pending judicial review.",
|
||||
"authority": "Court order 2026-17",
|
||||
"purpose": "preserve evidence",
|
||||
"recorded_at": NOW + timedelta(minutes=4),
|
||||
"idempotency_key": "record-hold-1",
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(RecordConflictError, "active record hold"):
|
||||
self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "transfer",
|
||||
"reason": "Transfer after retention.",
|
||||
"purpose": "dispose record",
|
||||
"recorded_at": NOW + timedelta(minutes=5),
|
||||
"idempotency_key": "record-disposition-blocked",
|
||||
},
|
||||
)
|
||||
self.records.release_hold(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
hold_id=str(hold["hold_id"]),
|
||||
payload={
|
||||
"expected_hold_revision": 1,
|
||||
"reason": "Judicial review is complete.",
|
||||
"purpose": "resume disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=6),
|
||||
"idempotency_key": "record-hold-release-1",
|
||||
},
|
||||
)
|
||||
disposition = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 3,
|
||||
"action": "transfer",
|
||||
"reason": "Transfer after independent review.",
|
||||
"purpose": "dispose record",
|
||||
"policy_refs": ["records-policy:v1"],
|
||||
"recorded_at": NOW + timedelta(minutes=7),
|
||||
"idempotency_key": "record-disposition-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_pending", disposition["status"])
|
||||
self.assertFalse(disposition["consequence_preview"]["external_effect"])
|
||||
self.records.registry.approvals.approved = True
|
||||
finalized = self.records.finalize_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id="disposition-1",
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "approve disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=8),
|
||||
"idempotency_key": "record-disposition-finalize-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["approved_state"], finalized["record"]["state"])
|
||||
package = self.records.prepare_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"package_id": "package-1",
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 4,
|
||||
"provider_id": "simulation",
|
||||
"profile": "govoplan-simulation-v1",
|
||||
"purpose": "validate archive transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=9),
|
||||
"idempotency_key": "record-package-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual("prepared", package["status"])
|
||||
receipt = self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-1",
|
||||
payload={
|
||||
"expected_package_revision": 1,
|
||||
"purpose": "validate archive transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=10),
|
||||
"idempotency_key": "record-package-dispatch-1",
|
||||
},
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(expected["transfer_status"], receipt["status"])
|
||||
self.assertTrue(receipt["simulated"])
|
||||
self.assertEqual(
|
||||
expected["custody_transferred"],
|
||||
receipt["receipt"]["metadata"]["custody_transferred"],
|
||||
)
|
||||
|
||||
detail = self.records.get_record(
|
||||
self.session, self.principal, record_id="record-1"
|
||||
)
|
||||
self.assertEqual("released", detail["holds"][0]["status"])
|
||||
self.assertEqual(
|
||||
expected["disposition_status"], detail["dispositions"][0]["status"]
|
||||
)
|
||||
self.assertEqual("simulated_accepted", detail["transfer_packages"][0]["status"])
|
||||
|
||||
unknown_package = self.records.prepare_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"package_id": "package-unknown",
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 4,
|
||||
"provider_id": "unknown_simulation",
|
||||
"profile": "govoplan-unknown-outcome-v1",
|
||||
"purpose": "exercise unknown transfer recovery",
|
||||
"recorded_at": NOW + timedelta(minutes=11),
|
||||
"idempotency_key": "record-package-unknown",
|
||||
},
|
||||
)
|
||||
unknown_receipt = self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-unknown",
|
||||
payload={
|
||||
"expected_package_revision": unknown_package["revision"],
|
||||
"purpose": "exercise unknown transfer recovery",
|
||||
"recorded_at": NOW + timedelta(minutes=12),
|
||||
"idempotency_key": "record-package-unknown-dispatch",
|
||||
},
|
||||
)
|
||||
self.assertEqual("outcome_unknown", unknown_receipt["status"])
|
||||
self.assertFalse(unknown_receipt["receipt"]["retry_safe"])
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "unknown outcomes require reconciliation"
|
||||
):
|
||||
self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-unknown",
|
||||
payload={
|
||||
"expected_package_revision": 2,
|
||||
"purpose": "must not retry unknown transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=13),
|
||||
"idempotency_key": "record-package-unknown-retry",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
1, self.records.registry.unknown_archive_provider.dispatch_count
|
||||
)
|
||||
self.assertEqual(
|
||||
"transfer_pending",
|
||||
self.records.get_record(self.session, self.principal, record_id="record-1")[
|
||||
"record"
|
||||
]["state"],
|
||||
)
|
||||
recovery = self.records.recovery_status(
|
||||
self.session, self.principal, record_id="record-1"
|
||||
)
|
||||
self.assertTrue(recovery["healthy"])
|
||||
self.assertTrue(recovery["package_checks"][0]["manifest_verified"])
|
||||
|
||||
def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn(
|
||||
self,
|
||||
) -> None:
|
||||
self.records.registry.approvals = None
|
||||
self._create_record()
|
||||
self.records.close_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 1,
|
||||
"purpose": "close record",
|
||||
"reason": "Work completed.",
|
||||
"recorded_at": NOW + timedelta(minutes=2),
|
||||
"idempotency_key": "close-without-approvals",
|
||||
},
|
||||
)
|
||||
self.records.appraise_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 2,
|
||||
"outcome": "retain",
|
||||
"purpose": "appraise record",
|
||||
"reason": "Retain permanently.",
|
||||
"override_retention_not_due": True,
|
||||
"recorded_at": NOW + timedelta(minutes=3),
|
||||
"idempotency_key": "appraise-without-approvals",
|
||||
},
|
||||
)
|
||||
proposal = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "retain",
|
||||
"purpose": "dispose record",
|
||||
"reason": "Retain permanently.",
|
||||
"recorded_at": NOW + timedelta(minutes=4),
|
||||
"idempotency_key": "proposal-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_unavailable", proposal["status"])
|
||||
with self.assertRaisesRegex(ValueError, "not awaiting an available approval"):
|
||||
self.records.finalize_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id=str(proposal["disposition_id"]),
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "finalize disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=5),
|
||||
"idempotency_key": "finalize-without-approvals",
|
||||
},
|
||||
)
|
||||
withdrawn = self.records.withdraw_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id=str(proposal["disposition_id"]),
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "correct disposition",
|
||||
"reason": "The appraisal evidence needs correction.",
|
||||
"recorded_at": NOW + timedelta(minutes=6),
|
||||
"idempotency_key": "withdraw-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("withdrawn", withdrawn["status"])
|
||||
replacement = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "retain",
|
||||
"purpose": "dispose record",
|
||||
"reason": "Corrected permanent-retention proposal.",
|
||||
"recorded_at": NOW + timedelta(minutes=7),
|
||||
"idempotency_key": "replacement-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_unavailable", replacement["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryCheckpoint,
|
||||
RecoveryOperation,
|
||||
RecoveryStatus,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
from govoplan_records.backend.recovery import (
|
||||
RecordRecoveryError,
|
||||
begin_record_atomic_recovery,
|
||||
)
|
||||
|
||||
|
||||
class RecordsRecoveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory(prefix="records-recovery-")
|
||||
self.database_path = Path(self.directory.name) / "recovery.db"
|
||||
self.engine = create_engine(f"sqlite:///{self.database_path}")
|
||||
for table in (
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="records-test-node",
|
||||
incarnation="11111111-1111-4111-8111-111111111111",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
bind_process_runtime_identity(None)
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_atomic_evidence_chain_replays_only_the_same_request(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
started.commit_success(
|
||||
self.session,
|
||||
result={"record_id": "record-1", "revision": 2},
|
||||
resource_id="record-1",
|
||||
)
|
||||
|
||||
operation = self.session.get(RecoveryOperation, started.operation_id)
|
||||
self.assertIsNotNone(operation)
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
||||
self.assertTrue(
|
||||
verify_recovery_evidence_chain(self.session, started.operation_id)
|
||||
)
|
||||
|
||||
replay = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
self.assertTrue(replay.replayed)
|
||||
self.assertIsNone(replay.operation)
|
||||
|
||||
with self.assertRaisesRegex(RecordRecoveryError, "not started"):
|
||||
begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 9},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
|
||||
def test_unresolved_resource_blocks_another_runtime_effect(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="transfer.simulate",
|
||||
idempotency_key="dispatch-1",
|
||||
request={"record_id": "record-1", "package_id": "package-1"},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="other-records-test-node",
|
||||
incarnation="22222222-2222-4222-8222-222222222222",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RecordRecoveryError, "Another runtime"):
|
||||
begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.reopen",
|
||||
idempotency_key="reopen-1",
|
||||
request={"record_id": "record-1"},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="records-test-node",
|
||||
incarnation="11111111-1111-4111-8111-111111111111",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
started.fail(summary="Test cleanup", error_type="TestInterruption")
|
||||
|
||||
def test_recovery_evidence_survives_database_restore(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="restore-close-1",
|
||||
request={"record_id": "record-restore", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-restore",
|
||||
)
|
||||
started.commit_success(
|
||||
self.session,
|
||||
result={"record_id": "record-restore", "revision": 2},
|
||||
resource_id="record-restore",
|
||||
)
|
||||
|
||||
restored_path = Path(self.directory.name) / "restored.db"
|
||||
with (
|
||||
sqlite3.connect(self.database_path) as source,
|
||||
sqlite3.connect(restored_path) as target,
|
||||
):
|
||||
source.backup(target)
|
||||
restored_engine = create_engine(f"sqlite:///{restored_path}")
|
||||
try:
|
||||
with Session(restored_engine) as restored:
|
||||
operation = restored.get(RecoveryOperation, started.operation_id)
|
||||
self.assertIsNotNone(operation)
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
||||
self.assertTrue(
|
||||
verify_recovery_evidence_chain(restored, started.operation_id)
|
||||
)
|
||||
finally:
|
||||
restored_engine.dispose()
|
||||
|
||||
|
||||
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.19",
|
||||
"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,369 @@
|
||||
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;
|
||||
closed_at?: string | null;
|
||||
retention_started_at?: string | null;
|
||||
retention_due_at?: string | null;
|
||||
retention_rule: Record<string, unknown>;
|
||||
appraisal_state?: string | null;
|
||||
appraisal: Record<string, unknown>;
|
||||
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 RecordHold = {
|
||||
hold_id: string;
|
||||
record_id: string;
|
||||
revision: number;
|
||||
status: string;
|
||||
reason: string;
|
||||
authority: string;
|
||||
scope: Record<string, unknown>;
|
||||
effective_from: string;
|
||||
effective_to?: string | null;
|
||||
released_at?: string | null;
|
||||
policy_refs: string[];
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordDisposition = {
|
||||
disposition_id: string;
|
||||
record_id: string;
|
||||
revision: number;
|
||||
action: "retain" | "transfer" | "destroy" | "reclassify";
|
||||
status: string;
|
||||
reason: string;
|
||||
subject_revision: number;
|
||||
subject_sha256: string;
|
||||
consequence_preview: Record<string, unknown>;
|
||||
policy_refs: string[];
|
||||
approval_request_id?: string | null;
|
||||
proposed_by?: string | null;
|
||||
reviewed_by?: string | null;
|
||||
reviewed_at?: string | null;
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordTransferPackage = {
|
||||
package_id: string;
|
||||
record_id: string;
|
||||
disposition_id: string;
|
||||
revision: number;
|
||||
record_revision: number;
|
||||
provider_id: string;
|
||||
profile: string;
|
||||
status: string;
|
||||
manifest_sha256: string;
|
||||
receipt_sha256?: string | null;
|
||||
external_reference?: string | null;
|
||||
simulated: boolean;
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordDetail = {
|
||||
record: RecordEntry;
|
||||
volumes: RecordVolume[];
|
||||
items: RecordItem[];
|
||||
chronology: RecordChronology[];
|
||||
holds: RecordHold[];
|
||||
dispositions: RecordDisposition[];
|
||||
transfer_packages: RecordTransferPackage[];
|
||||
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 type RecordArchiveProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
profiles: string[];
|
||||
authority_modes: string[];
|
||||
healthy: boolean;
|
||||
checked_at: string;
|
||||
last_success_at?: string | null;
|
||||
freshness_seconds?: number | null;
|
||||
limitations: string[];
|
||||
simulated: boolean;
|
||||
};
|
||||
|
||||
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 getRecordArchiveProviders(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordArchiveProvider[] }> {
|
||||
return apiFetch(settings, "/api/v1/records/archive-providers", { signal });
|
||||
}
|
||||
|
||||
export function writeFilePlanNode(settings: ApiSettings, payload: Record<string, unknown>): Promise<FilePlanNode> {
|
||||
return apiFetch(settings, "/api/v1/records/catalog/file-plan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function writeRecordClass(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordClass> {
|
||||
return apiFetch(settings, "/api/v1/records/catalog/classes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRecordVolume(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordVolume> {
|
||||
return recordMutation(settings, recordId, "volumes", payload);
|
||||
}
|
||||
|
||||
export function closeRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "close", payload);
|
||||
}
|
||||
|
||||
export function reopenRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "reopen", payload);
|
||||
}
|
||||
|
||||
export function appraiseRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "appraise", payload);
|
||||
}
|
||||
|
||||
export function applyRecordHold(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordHold> {
|
||||
return recordMutation(settings, recordId, "holds", payload);
|
||||
}
|
||||
|
||||
export function releaseRecordHold(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
holdId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordHold> {
|
||||
return recordMutation(settings, recordId, `holds/${encodeURIComponent(holdId)}/release`, payload);
|
||||
}
|
||||
|
||||
export function proposeRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordDisposition> {
|
||||
return recordMutation(settings, recordId, "dispositions", payload);
|
||||
}
|
||||
|
||||
export function finalizeRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
dispositionId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<{ disposition: RecordDisposition; record: RecordEntry }> {
|
||||
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/finalize`, payload);
|
||||
}
|
||||
|
||||
export function withdrawRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
dispositionId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordDisposition> {
|
||||
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/withdraw`, payload);
|
||||
}
|
||||
|
||||
export function prepareRecordTransfer(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordTransferPackage> {
|
||||
return recordMutation(settings, recordId, "transfer-packages", payload);
|
||||
}
|
||||
|
||||
export function dispatchRecordTransfer(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
packageId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordTransferPackage> {
|
||||
return recordMutation(settings, recordId, `transfer-packages/${encodeURIComponent(packageId)}/dispatch`, payload);
|
||||
}
|
||||
|
||||
export function getRecordRecoveryStatus(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal });
|
||||
}
|
||||
|
||||
function recordMutation<T>(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/${path}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
writeFilePlanNode,
|
||||
writeRecordClass,
|
||||
type RecordCatalog
|
||||
} from "../../api/records";
|
||||
|
||||
|
||||
export function RecordCatalogDialog({
|
||||
open,
|
||||
settings,
|
||||
catalog,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
catalog: RecordCatalog;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<"file-plan" | "class">("file-plan");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [filePlanNodeId, setFilePlanNodeId] = useState("");
|
||||
const [retentionDays, setRetentionDays] = useState("");
|
||||
const [closureTrigger, setClosureTrigger] = useState("record_closed");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKind("file-plan");
|
||||
setSelectedId("");
|
||||
resetFields();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
resetFields();
|
||||
return;
|
||||
}
|
||||
if (kind === "file-plan") {
|
||||
const item = catalog.file_plan.find((candidate) => candidate.node_id === selectedId);
|
||||
if (!item) return;
|
||||
setIdentifier(item.node_id);
|
||||
setCode(item.code);
|
||||
setLabel(item.label);
|
||||
setDescription(item.description ?? "");
|
||||
setParentId(item.parent_node_id ?? "");
|
||||
} else {
|
||||
const item = catalog.classes.find((candidate) => candidate.class_id === selectedId);
|
||||
if (!item) return;
|
||||
setIdentifier(item.class_id);
|
||||
setCode(item.key);
|
||||
setLabel(item.label);
|
||||
setDescription(item.description ?? "");
|
||||
setFilePlanNodeId(item.file_plan_node_id);
|
||||
setRetentionDays(item.retention_period_days == null ? "" : String(item.retention_period_days));
|
||||
setClosureTrigger(item.closure_trigger ?? "record_closed");
|
||||
}
|
||||
}, [catalog.classes, catalog.file_plan, kind, selectedId]);
|
||||
|
||||
function resetFields() {
|
||||
setIdentifier("");
|
||||
setCode("");
|
||||
setLabel("");
|
||||
setDescription("");
|
||||
setParentId("");
|
||||
setFilePlanNodeId(catalog.file_plan[0]?.node_id ?? "");
|
||||
setRetentionDays("");
|
||||
setClosureTrigger("record_closed");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const current = kind === "file-plan"
|
||||
? catalog.file_plan.find((item) => item.node_id === selectedId)
|
||||
: catalog.classes.find((item) => item.class_id === selectedId);
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (kind === "file-plan") {
|
||||
await writeFilePlanNode(settings, {
|
||||
node_id: identifier.trim(),
|
||||
code: code.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || null,
|
||||
parent_node_id: parentId || null,
|
||||
active: true,
|
||||
recorded_at: new Date().toISOString(),
|
||||
expected_revision: current?.revision,
|
||||
idempotency_key: randomId(),
|
||||
institutional_context: {}
|
||||
});
|
||||
} else {
|
||||
await writeRecordClass(settings, {
|
||||
class_id: identifier.trim(),
|
||||
file_plan_node_id: filePlanNodeId,
|
||||
key: code.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || null,
|
||||
metadata_requirements: [],
|
||||
allowed_source_types: [],
|
||||
retention_period_days: retentionDays === "" ? null : Number(retentionDays),
|
||||
closure_trigger: closureTrigger,
|
||||
access_mode: "tenant",
|
||||
active: true,
|
||||
recorded_at: new Date().toISOString(),
|
||||
expected_revision: current?.revision,
|
||||
idempotency_key: randomId(),
|
||||
institutional_context: {}
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error && reason.message ? reason.message : "The Records catalog could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = identifier.trim() && code.trim() && label.trim() && (kind === "file-plan" || filePlanNodeId);
|
||||
const options = kind === "file-plan" ? catalog.file_plan : catalog.classes;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Administer Records catalog"
|
||||
onClose={onClose}
|
||||
closeDisabled={saving}
|
||||
portal
|
||||
className="records-dialog"
|
||||
helpContextId="records.catalog.admin"
|
||||
footer={<><Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="records-catalog-form" variant="primary" disabledReason={saving ? "The catalog revision is being saved." : !isValid ? "Complete all required catalog fields." : undefined}>{saving ? "Saving" : "Save revision"}</Button></>}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<form id="records-catalog-form" className="records-dialog-form" onSubmit={submit}>
|
||||
<FormField label="Catalog object">
|
||||
<select value={kind} onChange={(event) => { setKind(event.target.value as typeof kind); setSelectedId(""); }}>
|
||||
<option value="file-plan">File-plan node</option>
|
||||
<option value="class">Record class</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Revision target">
|
||||
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)}>
|
||||
<option value="">New {kind === "file-plan" ? "file-plan node" : "record class"}</option>
|
||||
{options.map((item) => <option key={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""} value={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""}>{item.label} · revision {item.revision}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={kind === "file-plan" ? "Node ID" : "Class ID"}><input value={identifier} onChange={(event) => setIdentifier(event.target.value)} disabled={Boolean(selectedId)} required /></FormField>
|
||||
<FormField label={kind === "file-plan" ? "Code" : "Key"}><input value={code} onChange={(event) => setCode(event.target.value)} required /></FormField>
|
||||
<FormField label="Label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>
|
||||
{kind === "file-plan" ? <FormField label="Parent node"><select value={parentId} onChange={(event) => setParentId(event.target.value)}><option value="">Top level</option>{catalog.file_plan.filter((item) => item.node_id !== identifier).map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField> : <><FormField label="File-plan node"><select value={filePlanNodeId} onChange={(event) => setFilePlanNodeId(event.target.value)} required>{catalog.file_plan.map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField><FormField label="Retention period (days)"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger"><select value={closureTrigger} onChange={(event) => setClosureTrigger(event.target.value)}><option value="record_closed">Record closure</option><option value="calendar_month_end">End of closure month</option><option value="calendar_year_end">End of closure year</option><option value="explicit">Explicit date at closure</option></select></FormField></>}
|
||||
<FormField label="Description"><textarea rows={4} value={description} onChange={(event) => setDescription(event.target.value)} /></FormField>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
ArchiveRestore,
|
||||
Ban,
|
||||
Box,
|
||||
CheckCheck,
|
||||
CirclePause,
|
||||
FolderPlus,
|
||||
LockKeyhole,
|
||||
Play,
|
||||
RotateCcw
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
StatusBadge,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
applyRecordHold,
|
||||
appraiseRecord,
|
||||
closeRecord,
|
||||
createRecordVolume,
|
||||
dispatchRecordTransfer,
|
||||
finalizeRecordDisposition,
|
||||
getRecordRecoveryStatus,
|
||||
prepareRecordTransfer,
|
||||
proposeRecordDisposition,
|
||||
releaseRecordHold,
|
||||
reopenRecord,
|
||||
withdrawRecordDisposition,
|
||||
type RecordArchiveProvider,
|
||||
type RecordDetail,
|
||||
type RecordDisposition,
|
||||
type RecordTransferPackage
|
||||
} from "../../api/records";
|
||||
|
||||
|
||||
type ActionKind =
|
||||
| "volume"
|
||||
| "close"
|
||||
| "reopen"
|
||||
| "appraise"
|
||||
| "hold"
|
||||
| "release-hold"
|
||||
| "disposition"
|
||||
| "finalize"
|
||||
| "withdraw"
|
||||
| "prepare-transfer"
|
||||
| "dispatch-transfer"
|
||||
| "recovery";
|
||||
|
||||
export function RecordLifecyclePanel({
|
||||
detail,
|
||||
archiveProviders,
|
||||
settings,
|
||||
canWrite,
|
||||
canAdmin,
|
||||
onChanged
|
||||
}: {
|
||||
detail: RecordDetail;
|
||||
archiveProviders: RecordArchiveProvider[];
|
||||
settings: PlatformRouteContext["settings"];
|
||||
canWrite: boolean;
|
||||
canAdmin: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<ActionKind | null>(null);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [purpose, setPurpose] = useState(detail.record.purpose);
|
||||
const [authority, setAuthority] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [outcome, setOutcome] = useState<"retain" | "transfer" | "destroy" | "reclassify">("retain");
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [retentionTriggerAt, setRetentionTriggerAt] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [recovery, setRecovery] = useState<Record<string, unknown> | null>(null);
|
||||
const record = detail.record;
|
||||
const currentDisposition = detail.dispositions.find((item) => item.status !== "withdrawn");
|
||||
const currentPackage = detail.transfer_packages[0];
|
||||
const activeHolds = detail.holds.filter((item) => item.status === "active");
|
||||
const healthyProviders = archiveProviders.filter((item) => item.healthy);
|
||||
const selectedProvider = healthyProviders.find((item) => item.id === providerId);
|
||||
|
||||
useEffect(() => setPurpose(record.purpose), [record.purpose]);
|
||||
useEffect(() => {
|
||||
if (selectedProvider?.profiles.includes(profile)) return;
|
||||
setProfile(selectedProvider?.profiles[0] ?? "");
|
||||
}, [profile, selectedProvider]);
|
||||
|
||||
const availableActions = useMemo(() => ({
|
||||
canClose: record.state === "open",
|
||||
canReopen: ["closed", "retention_running", "appraised"].includes(record.state) && !currentDisposition,
|
||||
canAppraise: ["closed", "retention_running"].includes(record.state),
|
||||
canPropose: record.state === "appraised" && !currentDisposition,
|
||||
canFinalize: currentDisposition?.status === "review_pending",
|
||||
canPrepare: currentDisposition?.status === "approved" && currentDisposition.action === "transfer" && !currentPackage,
|
||||
canDispatch: currentPackage?.status === "prepared"
|
||||
}), [currentDisposition, currentPackage, record.state]);
|
||||
|
||||
function openAction(kind: ActionKind, id = "") {
|
||||
setAction(kind);
|
||||
setTargetId(id);
|
||||
setReason("");
|
||||
setAuthority("");
|
||||
setLabel("");
|
||||
setOutcome((record.appraisal_state as typeof outcome | null) ?? "retain");
|
||||
const provider = healthyProviders[0];
|
||||
setProviderId(provider?.id ?? "");
|
||||
setProfile(provider?.profiles[0] ?? "");
|
||||
setRetentionTriggerAt("");
|
||||
setRecovery(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function openRecovery() {
|
||||
openAction("recovery");
|
||||
setSaving(true);
|
||||
try {
|
||||
setRecovery(await getRecordRecoveryStatus(settings, record.record_id));
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, "Recovery evidence could not be loaded."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!action || action === "recovery") return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const recordedAt = new Date().toISOString();
|
||||
const idempotencyKey = randomId();
|
||||
try {
|
||||
if (action === "volume") {
|
||||
await createRecordVolume(settings, record.record_id, {
|
||||
label: label.trim(),
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "close") {
|
||||
await closeRecord(settings, record.record_id, {
|
||||
expected_revision: record.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
retention_trigger_at: retentionTriggerAt ? new Date(retentionTriggerAt).toISOString() : null,
|
||||
restart_retention: false,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "reopen") {
|
||||
await reopenRecord(settings, record.record_id, lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey));
|
||||
} else if (action === "appraise") {
|
||||
await appraiseRecord(settings, record.record_id, {
|
||||
...lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey),
|
||||
outcome,
|
||||
policy_refs: [],
|
||||
override_retention_not_due: canAdmin && record.retention_due_at != null && new Date(record.retention_due_at) > new Date()
|
||||
});
|
||||
} else if (action === "hold") {
|
||||
await applyRecordHold(settings, record.record_id, {
|
||||
expected_record_revision: record.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
authority: authority.trim(),
|
||||
scope: {},
|
||||
policy_refs: [],
|
||||
institutional_context: record.institutional_context,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "release-hold") {
|
||||
const hold = detail.holds.find((item) => item.hold_id === targetId);
|
||||
if (!hold) throw new Error("The selected hold is no longer available.");
|
||||
await releaseRecordHold(settings, record.record_id, hold.hold_id, {
|
||||
expected_hold_revision: hold.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "disposition") {
|
||||
await proposeRecordDisposition(settings, record.record_id, {
|
||||
expected_record_revision: record.revision,
|
||||
action: outcome,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
policy_refs: [],
|
||||
institutional_context: record.institutional_context,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "finalize") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await finalizeRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
|
||||
expected_disposition_revision: currentDisposition.revision,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "withdraw") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await withdrawRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
|
||||
expected_disposition_revision: currentDisposition.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "prepare-transfer") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await prepareRecordTransfer(settings, record.record_id, {
|
||||
disposition_id: currentDisposition.disposition_id,
|
||||
expected_record_revision: record.revision,
|
||||
provider_id: providerId,
|
||||
profile,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "dispatch-transfer") {
|
||||
if (!currentPackage) throw new Error("The transfer package is no longer available.");
|
||||
await dispatchRecordTransfer(settings, record.record_id, currentPackage.package_id, {
|
||||
expected_package_revision: currentPackage.revision,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
}
|
||||
setAction(null);
|
||||
onChanged();
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, "The Records lifecycle action failed."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="records-detail-section" data-help-context-id="records.lifecycle">
|
||||
<div className="records-section-heading">
|
||||
<h2>Lifecycle</h2>
|
||||
<StatusBadge status={activeHolds.length > 0 ? "danger" : "active"} label={activeHolds.length > 0 ? `${activeHolds.length} active hold(s)` : humanize(record.state)} />
|
||||
</div>
|
||||
<div className="records-lifecycle-actions">
|
||||
<Button type="button" variant="ghost" onClick={() => openAction("volume")} disabledReason={!canWrite ? "Your account may not create record volumes." : undefined}>
|
||||
<FolderPlus size={16} aria-hidden="true" /> Add volume
|
||||
</Button>
|
||||
{availableActions.canClose && <Button type="button" variant="ghost" onClick={() => openAction("close")}><CirclePause size={16} aria-hidden="true" /> Close record</Button>}
|
||||
{availableActions.canReopen && <Button type="button" variant="ghost" onClick={() => openAction("reopen")}><RotateCcw size={16} aria-hidden="true" /> Reopen</Button>}
|
||||
{availableActions.canAppraise && <Button type="button" variant="ghost" onClick={() => openAction("appraise")}><CheckCheck size={16} aria-hidden="true" /> Appraise</Button>}
|
||||
<Button type="button" variant="ghost" onClick={() => openAction("hold")} disabledReason={!canWrite ? "Your account may not apply a hold." : undefined}><LockKeyhole size={16} aria-hidden="true" /> Apply hold</Button>
|
||||
{availableActions.canPropose && <Button type="button" variant="primary" onClick={() => openAction("disposition")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><ArchiveRestore size={16} aria-hidden="true" /> Propose disposition</Button>}
|
||||
{availableActions.canFinalize && <Button type="button" variant="primary" onClick={() => openAction("finalize")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><CheckCheck size={16} aria-hidden="true" /> Finalize approved disposition</Button>}
|
||||
{currentDisposition && ["review_pending", "review_unavailable"].includes(currentDisposition.status) && <Button type="button" variant="ghost" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>}
|
||||
{availableActions.canPrepare && <Button type="button" variant="primary" onClick={() => openAction("prepare-transfer")} disabledReason={healthyProviders.length === 0 ? "No healthy archive profile is available." : activeHolds.length ? "Release all active holds first." : undefined}><Box size={16} aria-hidden="true" /> Prepare transfer</Button>}
|
||||
{availableActions.canDispatch && <Button type="button" variant="primary" onClick={() => openAction("dispatch-transfer")} disabledReason={!currentPackage?.simulated ? "Real archive dispatch needs a target-specific recovery profile." : activeHolds.length ? "Release all active holds first." : undefined}><Play size={16} aria-hidden="true" /> Run simulation</Button>}
|
||||
{canAdmin && <Button type="button" variant="ghost" onClick={openRecovery}><ArchiveRestore size={16} aria-hidden="true" /> Recovery evidence</Button>}
|
||||
</div>
|
||||
|
||||
<LifecycleEvidence detail={detail} onRelease={(holdId) => openAction("release-hold", holdId)} canWrite={canWrite} />
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={action != null}
|
||||
title={dialogTitle(action)}
|
||||
onClose={() => !saving && setAction(null)}
|
||||
closeDisabled={saving}
|
||||
portal
|
||||
className="records-dialog"
|
||||
helpContextId={`records.lifecycle.${action ?? "action"}`}
|
||||
footer={action === "recovery" ? <Button type="button" variant="primary" onClick={() => setAction(null)}>Close</Button> : (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => setAction(null)} disabled={saving}>Cancel</Button>
|
||||
<Button type="submit" form="records-lifecycle-form" variant="primary" disabledReason={submitDisabledReason(action, { reason, purpose, authority, label, providerId, profile, saving })}>{saving ? "Saving" : actionLabel(action)}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{action === "recovery" ? <RecoveryResult value={recovery} loading={saving} /> : (
|
||||
<form id="records-lifecycle-form" className="records-dialog-form" onSubmit={submit}>
|
||||
{action === "volume" && <FormField label="Volume label" helpContextId="records.field.volume-label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>}
|
||||
{["appraise", "disposition"].includes(action ?? "") && <FormField label="Disposition" helpContextId="records.field.disposition"><select value={outcome} onChange={(event) => setOutcome(event.target.value as typeof outcome)}><option value="retain">Retain</option><option value="transfer">Transfer</option><option value="destroy">Destroy</option><option value="reclassify">Reclassify</option></select></FormField>}
|
||||
{action === "hold" && <FormField label="Authority" helpContextId="records.field.hold-authority"><input value={authority} onChange={(event) => setAuthority(event.target.value)} required /></FormField>}
|
||||
{action === "close" && <FormField label="Explicit retention trigger" helpContextId="records.field.retention-trigger"><input type="datetime-local" value={retentionTriggerAt} onChange={(event) => setRetentionTriggerAt(event.target.value)} /></FormField>}
|
||||
{action === "prepare-transfer" && <><FormField label="Archive provider" helpContextId="records.field.archive-provider"><select value={providerId} onChange={(event) => setProviderId(event.target.value)} required>{healthyProviders.map((item) => <option key={item.id} value={item.id}>{item.label}{item.simulated ? " (simulation)" : ""}</option>)}</select></FormField><FormField label="Transfer profile" helpContextId="records.field.archive-profile"><select value={profile} onChange={(event) => setProfile(event.target.value)} required>{selectedProvider?.profiles.map((item) => <option key={item} value={item}>{item}</option>)}</select></FormField></>}
|
||||
{action === "dispatch-transfer" && <DismissibleAlert tone="warning" dismissible={false} compact>This validates transfer handling only. Archival custody is not transferred.</DismissibleAlert>}
|
||||
{action === "finalize" && currentDisposition?.approval_request_id && <DismissibleAlert tone="info" dismissible={false} compact>Approval request <a href="/approvals">{currentDisposition.approval_request_id}</a> must be approved by another account.</DismissibleAlert>}
|
||||
{requiresReason(action) && <FormField label="Reason" helpContextId="records.field.lifecycle-reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={4} required /></FormField>}
|
||||
<FormField label="Purpose"><input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /></FormField>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDetail; onRelease: (holdId: string) => void; canWrite: boolean }) {
|
||||
const record = detail.record;
|
||||
return (
|
||||
<div className="records-lifecycle-evidence">
|
||||
<dl>
|
||||
<div><dt>Closed</dt><dd>{formatDateTime(record.closed_at)}</dd></div>
|
||||
<div><dt>Retention starts</dt><dd>{formatDateTime(record.retention_started_at)}</dd></div>
|
||||
<div><dt>Retention due</dt><dd>{formatDateTime(record.retention_due_at)}</dd></div>
|
||||
<div><dt>Appraisal</dt><dd>{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}</dd></div>
|
||||
</dl>
|
||||
{detail.volumes.length > 0 && <div className="records-evidence-list"><strong>Volumes</strong>{detail.volumes.map((volume) => <div key={volume.volume_id}><span>{volume.sequence}. {volume.label}</span><StatusBadge status={volume.state === "open" ? "active" : "neutral"} label={humanize(volume.state)} /></div>)}</div>}
|
||||
{detail.holds.length > 0 && <div className="records-evidence-list"><strong>Holds</strong>{detail.holds.map((hold) => <div key={hold.hold_id}><span><b>{hold.authority}</b> · {hold.reason}</span><span className="records-evidence-actions"><StatusBadge status={hold.status === "active" ? "danger" : "neutral"} label={humanize(hold.status)} />{hold.status === "active" && <Button type="button" variant="ghost" iconOnly aria-label="Release hold" title="Release hold" onClick={() => onRelease(hold.hold_id)} disabledReason={!canWrite ? "Your account may not release holds." : undefined}><Ban size={15} aria-hidden="true" /></Button>}</span></div>)}</div>}
|
||||
{detail.dispositions.map((item) => <DispositionEvidence key={item.disposition_id} item={item} />)}
|
||||
{detail.transfer_packages.map((item) => <TransferEvidence key={item.package_id} item={item} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DispositionEvidence({ item }: { item: RecordDisposition }) {
|
||||
return <div className="records-evidence-list"><strong>Disposition</strong><div><span><b>{humanize(item.action)}</b> · {item.reason}</span><StatusBadge status={item.status === "approved" ? "active" : item.status === "review_unavailable" ? "danger" : "warning"} label={humanize(item.status)} /></div>{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>;
|
||||
}
|
||||
|
||||
function TransferEvidence({ item }: { item: RecordTransferPackage }) {
|
||||
return <div className="records-evidence-list"><strong>Transfer package</strong><div><span><b>{item.profile}</b> · {item.manifest_sha256.slice(0, 12)}</span><StatusBadge status={item.status === "simulated_accepted" ? "warning" : item.status === "accepted" ? "active" : "neutral"} label={humanize(item.status)} /></div>{item.simulated && <small>Simulation only; no archival custody.</small>}</div>;
|
||||
}
|
||||
|
||||
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
|
||||
if (loading) return <p>Loading recovery evidence...</p>;
|
||||
if (!value) return null;
|
||||
const healthy = Boolean(value.healthy);
|
||||
return <div className="records-recovery-result"><DismissibleAlert tone={healthy ? "success" : "danger"} dismissible={false}>{healthy ? "Recovery evidence is complete." : `${String(value.failure_count)} recovery check(s) require attention.`}</DismissibleAlert><dl><div><dt>Evidence digest</dt><dd>{String(value.evidence_sha256)}</dd></div><div><dt>Source checks</dt><dd>{Array.isArray(value.source_checks) ? value.source_checks.length : 0}</dd></div><div><dt>Recovery operations</dt><dd>{Array.isArray(value.recovery_operations) ? value.recovery_operations.length : 0}</dd></div></dl></div>;
|
||||
}
|
||||
|
||||
function lifecyclePayload(expectedRevision: number, purpose: string, reason: string, recordedAt: string, idempotencyKey: string) {
|
||||
return { expected_revision: expectedRevision, purpose: purpose.trim(), reason: reason.trim(), recorded_at: recordedAt, idempotency_key: idempotencyKey };
|
||||
}
|
||||
|
||||
function requiresReason(action: ActionKind | null) {
|
||||
return action != null && ["close", "reopen", "appraise", "hold", "release-hold", "disposition", "withdraw"].includes(action);
|
||||
}
|
||||
|
||||
function dialogTitle(action: ActionKind | null) {
|
||||
if (action === "close") return "Close record";
|
||||
return action ? humanize(action) : "Records action";
|
||||
}
|
||||
|
||||
function actionLabel(action: ActionKind | null) {
|
||||
if (action === "dispatch-transfer") return "Run simulation";
|
||||
if (action === "finalize") return "Finalize";
|
||||
if (action === "close") return "Close record";
|
||||
return action ? humanize(action) : "Save";
|
||||
}
|
||||
|
||||
function submitDisabledReason(action: ActionKind | null, values: { reason: string; purpose: string; authority: string; label: string; providerId: string; profile: string; saving: boolean }) {
|
||||
if (values.saving) return "The lifecycle action is being saved.";
|
||||
if (!values.purpose.trim()) return "Enter the purpose for this action.";
|
||||
if (requiresReason(action) && !values.reason.trim()) return "Enter a reason for this action.";
|
||||
if (action === "hold" && !values.authority.trim()) return "Enter the hold authority.";
|
||||
if (action === "volume" && !values.label.trim()) return "Enter a volume label.";
|
||||
if (action === "prepare-transfer" && (!values.providerId || !values.profile)) return "Select an available archive profile.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
|
||||
}
|
||||
|
||||
function humanize(value: string) {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string) {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
import {
|
||||
Archive,
|
||||
FilePlus2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2
|
||||
} 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,
|
||||
getRecordArchiveProviders,
|
||||
getRecordCatalog,
|
||||
getRecordSources,
|
||||
listRecords,
|
||||
updateRecord,
|
||||
type FilePlanNode,
|
||||
type RecordArchiveProvider,
|
||||
type RecordCatalog,
|
||||
type RecordClass,
|
||||
type RecordDetail,
|
||||
type RecordEntry,
|
||||
type RecordSourceProvider
|
||||
} from "../../api/records";
|
||||
import { RecordCatalogDialog } from "./RecordCatalogDialog";
|
||||
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
|
||||
import {
|
||||
RECORDS_DOCUMENTATION,
|
||||
RECORDS_FIELD_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
|
||||
const EMPTY_CATALOG: RecordCatalog = { file_plan: [], classes: [] };
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type FilingPreset = {
|
||||
sourceModule: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
sourceRevision: string;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
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 [archiveProviders, setArchiveProviders] = useState<RecordArchiveProvider[]>([]);
|
||||
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 [catalogOpen, setCatalogOpen] = useState(false);
|
||||
const canWrite = auth.scopes.includes("records:workspace:write") ||
|
||||
auth.scopes.includes("records:workspace:admin");
|
||||
const canAdmin = auth.scopes.includes("records:workspace:admin");
|
||||
const filingPreset = useMemo(() => filingPresetFrom(searchParams), [searchParams]);
|
||||
|
||||
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[] }),
|
||||
canWrite
|
||||
? getRecordArchiveProviders(settings, controller.signal)
|
||||
: Promise.resolve({ providers: [] as RecordArchiveProvider[] })
|
||||
]).
|
||||
then(([nextCatalog, sourceResult, archiveResult]) => {
|
||||
setCatalog(nextCatalog);
|
||||
setSources(sourceResult.providers);
|
||||
setArchiveProviders(archiveResult.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>
|
||||
{canAdmin && (
|
||||
<Button type="button" variant="ghost" onClick={() => setCatalogOpen(true)} helpContextId="records.catalog.admin">
|
||||
<Settings2 size={16} aria-hidden="true" />
|
||||
Catalog
|
||||
</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>}
|
||||
{filingPreset &&
|
||||
<DismissibleAlert tone="info" dismissible={false} compact>
|
||||
{i18nMessage("i18n:govoplan-records.source_ready", {
|
||||
value0: filingPreset.sourceLabel || "An exact source revision"
|
||||
})} {i18nMessage("i18n:govoplan-records.select_destination")}
|
||||
</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)}
|
||||
settings={settings}
|
||||
archiveProviders={archiveProviders}
|
||||
canWrite={canWrite}
|
||||
canAdmin={canAdmin}
|
||||
hasSources={sources.length > 0}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onFile={() => setFileOpen(true)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
volumes={detail?.volumes ?? []}
|
||||
providers={sources}
|
||||
preset={filingPreset}
|
||||
onClose={() => setFileOpen(false)}
|
||||
onSaved={() => {
|
||||
setFileOpen(false);
|
||||
clearFilingPreset(searchParams, setSearchParams);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
<RecordCatalogDialog
|
||||
open={catalogOpen}
|
||||
settings={settings}
|
||||
catalog={catalog}
|
||||
onClose={() => setCatalogOpen(false)}
|
||||
onSaved={() => {
|
||||
setCatalogOpen(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetailPanel({
|
||||
detail,
|
||||
recordClass,
|
||||
settings,
|
||||
archiveProviders,
|
||||
canWrite,
|
||||
canAdmin,
|
||||
hasSources,
|
||||
onEdit,
|
||||
onFile,
|
||||
onChanged
|
||||
}: {
|
||||
detail: RecordDetail;
|
||||
recordClass?: RecordClass;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
archiveProviders: RecordArchiveProvider[];
|
||||
canWrite: boolean;
|
||||
canAdmin: boolean;
|
||||
hasSources: boolean;
|
||||
onEdit: () => void;
|
||||
onFile: () => void;
|
||||
onChanged: () => 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." : record.state !== "open" ? "Only open records accept new 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>}
|
||||
|
||||
<RecordLifecyclePanel
|
||||
detail={detail}
|
||||
archiveProviders={archiveProviders}
|
||||
settings={settings}
|
||||
canWrite={canWrite}
|
||||
canAdmin={canAdmin}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
|
||||
<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,
|
||||
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { 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">
|
||||
{mode === "edit" && record && !["planned", "open"].includes(record.state) ? (
|
||||
<input value={humanize(record.state)} disabled />
|
||||
) : (
|
||||
<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,
|
||||
volumes,
|
||||
providers,
|
||||
preset,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
record: RecordEntry | null;
|
||||
volumes: RecordDetail["volumes"];
|
||||
providers: RecordSourceProvider[];
|
||||
preset: FilingPreset | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [sourceModule, setSourceModule] = useState("");
|
||||
const [resourceType, setResourceType] = useState("");
|
||||
const [resourceId, setResourceId] = useState("");
|
||||
const [sourceRevision, setSourceRevision] = useState("");
|
||||
const [volumeId, setVolumeId] = 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 requested = preset
|
||||
? providers.find((item) => item.source_module === preset.sourceModule)
|
||||
: undefined;
|
||||
const first = requested ?? providers[0];
|
||||
setSourceModule(first?.source_module ?? "");
|
||||
setResourceType(
|
||||
requested?.resource_types.includes(preset?.resourceType ?? "")
|
||||
? preset!.resourceType
|
||||
: first?.resource_types[0] ?? ""
|
||||
);
|
||||
setResourceId(requested ? preset?.resourceId ?? "" : "");
|
||||
setSourceRevision(requested ? preset?.sourceRevision ?? "" : "");
|
||||
setVolumeId("");
|
||||
setPurpose(record?.purpose ?? "");
|
||||
setFilingReason(requested && preset?.sourceLabel ? `File ${preset.sourceLabel}.` : "");
|
||||
setError("");
|
||||
}, [open, preset, 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",
|
||||
volume_id: volumeId || null,
|
||||
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="Record volume" helpContextId="records.field.volume">
|
||||
<select value={volumeId} onChange={(event) => setVolumeId(event.target.value)}>
|
||||
<option value="">No volume</option>
|
||||
{volumes.map((item) => <option key={item.volume_id} value={item.volume_id}>{item.sequence}. {item.label}</option>)}
|
||||
</select>
|
||||
</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 filingPresetFrom(searchParams: URLSearchParams): FilingPreset | null {
|
||||
const sourceModule = searchParams.get("sourceModule")?.trim() ?? "";
|
||||
const resourceType = searchParams.get("resourceType")?.trim() ?? "";
|
||||
const resourceId = searchParams.get("resourceId")?.trim() ?? "";
|
||||
const sourceRevision = searchParams.get("sourceRevision")?.trim() ?? "";
|
||||
if (!sourceModule || !resourceType || !resourceId || !sourceRevision) return null;
|
||||
return {
|
||||
sourceModule,
|
||||
resourceType,
|
||||
resourceId,
|
||||
sourceRevision,
|
||||
sourceLabel: (searchParams.get("sourceLabel")?.trim() ?? "").slice(0, 300)
|
||||
};
|
||||
}
|
||||
|
||||
function clearFilingPreset(
|
||||
searchParams: URLSearchParams,
|
||||
setSearchParams: ReturnType<typeof useSearchParams>[1]
|
||||
) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const key of ["sourceModule", "resourceType", "resourceId", "sourceRevision", "sourceLabel"]) {
|
||||
next.delete(key);
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
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,310 @@
|
||||
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",
|
||||
"i18n:govoplan-records.source_ready": "{value0} is ready for filing.",
|
||||
"i18n:govoplan-records.select_destination": "Select the destination record, then choose File item.",
|
||||
"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.",
|
||||
"Catalog": "Catalog",
|
||||
"Administer Records catalog": "Administer Records catalog",
|
||||
"Catalog object": "Catalog object",
|
||||
"File-plan node": "File-plan node",
|
||||
"Revision target": "Revision target",
|
||||
"Node ID": "Node ID",
|
||||
"Class ID": "Class ID",
|
||||
"Code": "Code",
|
||||
"Key": "Key",
|
||||
"Label": "Label",
|
||||
"Parent node": "Parent node",
|
||||
"Top level": "Top level",
|
||||
"Retention period (days)": "Retention period (days)",
|
||||
"Retention trigger": "Retention trigger",
|
||||
"Record closure": "Record closure",
|
||||
"End of closure month": "End of closure month",
|
||||
"End of closure year": "End of closure year",
|
||||
"Explicit date at closure": "Explicit date at closure",
|
||||
"Save revision": "Save revision",
|
||||
"Lifecycle": "Lifecycle",
|
||||
"Add volume": "Add volume",
|
||||
"Close": "Close",
|
||||
"Close record": "Close record",
|
||||
"Reopen": "Reopen",
|
||||
"Appraise": "Appraise",
|
||||
"Apply hold": "Apply hold",
|
||||
"Propose disposition": "Propose disposition",
|
||||
"Finalize approved disposition": "Finalize approved disposition",
|
||||
"Withdraw proposal": "Withdraw proposal",
|
||||
"Prepare transfer": "Prepare transfer",
|
||||
"Run simulation": "Run simulation",
|
||||
"Recovery evidence": "Recovery evidence",
|
||||
"Closed": "Closed",
|
||||
"Retention starts": "Retention starts",
|
||||
"Retention due": "Retention due",
|
||||
"Appraisal": "Appraisal",
|
||||
"Not appraised": "Not appraised",
|
||||
"Volumes": "Volumes",
|
||||
"Holds": "Holds",
|
||||
"Disposition": "Disposition",
|
||||
"Transfer package": "Transfer package",
|
||||
"Simulation only; no archival custody.": "Simulation only; no archival custody.",
|
||||
"Volume label": "Volume label",
|
||||
"Authority": "Authority",
|
||||
"Explicit retention trigger": "Explicit retention trigger",
|
||||
"Archive provider": "Archive provider",
|
||||
"Transfer profile": "Transfer profile",
|
||||
"Reason": "Reason",
|
||||
"Retain": "Retain",
|
||||
"Transfer": "Transfer",
|
||||
"Destroy": "Destroy",
|
||||
"Reclassify": "Reclassify",
|
||||
"Record volume": "Record volume",
|
||||
"No volume": "No volume",
|
||||
"This validates transfer handling only. Archival custody is not transferred.": "This validates transfer handling only. Archival custody is not transferred.",
|
||||
"Recovery evidence is complete.": "Recovery evidence is complete.",
|
||||
"Evidence digest": "Evidence digest",
|
||||
"Source checks": "Source checks",
|
||||
"Recovery operations": "Recovery operations",
|
||||
"Loading recovery evidence...": "Loading recovery evidence...",
|
||||
"Only open records accept new items.": "Only open records accept new items.",
|
||||
"Your account may not create record volumes.": "Your account may not create record volumes.",
|
||||
"Your account may not apply a hold.": "Your account may not apply a hold.",
|
||||
"Your account may not release holds.": "Your account may not release holds.",
|
||||
"Release all active holds first.": "Release all active holds first.",
|
||||
"No healthy archive profile is available.": "No healthy archive profile is available.",
|
||||
"Real archive dispatch needs a target-specific recovery profile.": "Real archive dispatch needs a target-specific recovery profile.",
|
||||
"The lifecycle action is being saved.": "The lifecycle action is being saved.",
|
||||
"Enter the purpose for this action.": "Enter the purpose for this action.",
|
||||
"Enter a reason for this action.": "Enter a reason for this action.",
|
||||
"Enter the hold authority.": "Enter the hold authority.",
|
||||
"Enter a volume label.": "Enter a volume label.",
|
||||
"Select an available archive profile.": "Select an available archive profile."
|
||||
} 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",
|
||||
"i18n:govoplan-records.source_ready": "{value0} kann veraktet werden.",
|
||||
"i18n:govoplan-records.select_destination": "Wählen Sie die Zielakte und anschließend Objekt verakten.",
|
||||
"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.",
|
||||
"Catalog": "Katalog",
|
||||
"Administer Records catalog": "Aktenkatalog verwalten",
|
||||
"Catalog object": "Katalogobjekt",
|
||||
"File-plan node": "Aktenplanposition",
|
||||
"Revision target": "Revisionsziel",
|
||||
"Node ID": "Positions-ID",
|
||||
"Class ID": "Klassen-ID",
|
||||
"Code": "Kennzeichen",
|
||||
"Key": "Schlüssel",
|
||||
"Label": "Bezeichnung",
|
||||
"Parent node": "Übergeordnete Position",
|
||||
"Top level": "Oberste Ebene",
|
||||
"Retention period (days)": "Aufbewahrungsfrist (Tage)",
|
||||
"Retention trigger": "Beginn der Aufbewahrung",
|
||||
"Record closure": "Aktenabschluss",
|
||||
"End of closure month": "Ende des Abschlussmonats",
|
||||
"End of closure year": "Ende des Abschlussjahres",
|
||||
"Explicit date at closure": "Explizites Datum beim Abschluss",
|
||||
"Save revision": "Revision speichern",
|
||||
"Lifecycle": "Lebenszyklus",
|
||||
"Add volume": "Band anlegen",
|
||||
"Close": "Schließen",
|
||||
"Close record": "Akte abschließen",
|
||||
"Reopen": "Wiedereröffnen",
|
||||
"Appraise": "Bewerten",
|
||||
"Apply hold": "Sperre setzen",
|
||||
"Propose disposition": "Aussonderung vorschlagen",
|
||||
"Finalize approved disposition": "Freigegebene Aussonderung abschließen",
|
||||
"Withdraw proposal": "Vorschlag zurückziehen",
|
||||
"Prepare transfer": "Übergabe vorbereiten",
|
||||
"Run simulation": "Simulation ausführen",
|
||||
"Recovery evidence": "Wiederherstellungsnachweis",
|
||||
"Closed": "Abgeschlossen",
|
||||
"Retention starts": "Aufbewahrung beginnt",
|
||||
"Retention due": "Aufbewahrung endet",
|
||||
"Appraisal": "Bewertung",
|
||||
"Not appraised": "Nicht bewertet",
|
||||
"Volumes": "Bände",
|
||||
"Holds": "Sperren",
|
||||
"Disposition": "Aussonderung",
|
||||
"Transfer package": "Übergabepaket",
|
||||
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
|
||||
"Volume label": "Bandbezeichnung",
|
||||
"Authority": "Anordnende Stelle",
|
||||
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
|
||||
"Archive provider": "Archivanbieter",
|
||||
"Transfer profile": "Übergabeprofil",
|
||||
"Reason": "Begründung",
|
||||
"Retain": "Aufbewahren",
|
||||
"Transfer": "Übergeben",
|
||||
"Destroy": "Vernichten",
|
||||
"Reclassify": "Neu klassifizieren",
|
||||
"Record volume": "Aktenband",
|
||||
"No volume": "Kein Band",
|
||||
"This validates transfer handling only. Archival custody is not transferred.": "Dies prüft nur die Übergabeverarbeitung. Eine Archivverwahrung wird nicht übertragen.",
|
||||
"Recovery evidence is complete.": "Der Wiederherstellungsnachweis ist vollständig.",
|
||||
"Evidence digest": "Nachweisprüfsumme",
|
||||
"Source checks": "Quellprüfungen",
|
||||
"Recovery operations": "Wiederherstellungsvorgänge",
|
||||
"Loading recovery evidence...": "Wiederherstellungsnachweis wird geladen...",
|
||||
"Only open records accept new items.": "Nur offene Akten nehmen neue Objekte auf.",
|
||||
"Your account may not create record volumes.": "Ihr Konto darf keine Aktenbände anlegen.",
|
||||
"Your account may not apply a hold.": "Ihr Konto darf keine Sperre setzen.",
|
||||
"Your account may not release holds.": "Ihr Konto darf keine Sperre aufheben.",
|
||||
"Release all active holds first.": "Heben Sie zuerst alle aktiven Sperren auf.",
|
||||
"No healthy archive profile is available.": "Es ist kein verfügbares Archivprofil betriebsbereit.",
|
||||
"Real archive dispatch needs a target-specific recovery profile.": "Eine echte Archivübergabe benötigt ein zielspezifisches Wiederherstellungsprofil.",
|
||||
"The lifecycle action is being saved.": "Die Lebenszyklusaktion wird gespeichert.",
|
||||
"Enter the purpose for this action.": "Geben Sie den Zweck dieser Aktion an.",
|
||||
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
|
||||
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
|
||||
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
|
||||
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil."
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, recordsModule } from "./module";
|
||||
export * from "./api/records";
|
||||
@@ -0,0 +1,55 @@
|
||||
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",
|
||||
"approvals",
|
||||
"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,620 @@
|
||||
.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-lifecycle-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding: 10px 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl,
|
||||
.records-recovery-result dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl > div,
|
||||
.records-recovery-result dl > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence dt,
|
||||
.records-recovery-result dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence dd,
|
||||
.records-recovery-result dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.records-evidence-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.records-evidence-list > strong {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.records-evidence-list > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
border-top: var(--border-line);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.records-evidence-list > div > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.records-evidence-list small {
|
||||
color: var(--muted);
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.records-evidence-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.records-recovery-result {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.records-recovery-result dl {
|
||||
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl {
|
||||
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-lifecycle-evidence > dl,
|
||||
.records-recovery-result dl,
|
||||
.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