Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dd280c47 | ||
|
|
ecfcdd1fea | ||
|
|
312a8e281e | ||
|
|
a5eee2c23f | ||
|
|
38f203a906 | ||
|
|
e9d2827581 | ||
|
|
6159bde2b5 | ||
|
|
e7ee0b8017 | ||
|
|
3800ad4662 | ||
|
|
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
|
# 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
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN Records platform module seed.
|
This repository owns the GovOPlaN eAkte and institutional Records domain.
|
||||||
|
|
||||||
Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.
|
Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.
|
||||||
|
|
||||||
@@ -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`.
|
- Depend on kernel contracts from `govoplan-core` and access contracts from `govoplan-access`.
|
||||||
- Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics.
|
- Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics.
|
||||||
- Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior.
|
- Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior.
|
||||||
- Keep the seed non-invasive until runtime routes, persistence, and WebUI flows are intentionally designed.
|
- Preserve exact source revisions and current source authorization at every filing boundary.
|
||||||
|
- Keep lifecycle and archive effects explicit, idempotent, recoverable, and separately governed.
|
||||||
|
|
||||||
## Local Workflow
|
## Local Workflow
|
||||||
|
|
||||||
@@ -20,5 +27,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is
|
|||||||
Focused verification:
|
Focused verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,9 +4,25 @@
|
|||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-records` is the GovOPlaN platform module seed for records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.
|
`govoplan-records` owns GovOPlaN's native eAkte boundary: versioned file plans
|
||||||
|
and record classes, stable record identities, immutable record revisions,
|
||||||
|
volumes, exact filing references, and record chronology. Source modules retain
|
||||||
|
authority over their objects and bytes.
|
||||||
|
|
||||||
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
|
The first working vertical slice includes PostgreSQL/SQLite models and an
|
||||||
|
Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent
|
||||||
|
filing, temporal reads, search registration, a full-height Records workspace,
|
||||||
|
provider-neutral filing from Files, Cases, Forms Runtime, and Decisions, and a
|
||||||
|
governed close/retention/hold/appraisal/disposition/transfer lifecycle.
|
||||||
|
|
||||||
|
The `privacy.dsar.records` capability exports bounded record and lifecycle
|
||||||
|
metadata from an explicit record or authoritative source linkage. Canonical
|
||||||
|
account, identity, and membership selectors return minimized staff
|
||||||
|
accountability attribution; they do not turn a staff action into record-subject
|
||||||
|
ownership. Immutable eAkte evidence is retained, while the current record fact
|
||||||
|
is routed to manual lifecycle review. Source content, snapshots, opaque
|
||||||
|
contexts, payloads, hashes, replay keys, and archive manifests/receipts are not
|
||||||
|
exported by Records.
|
||||||
|
|
||||||
## Initial Ownership
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -29,29 +45,60 @@ Detailed boundary notes are in [docs/RECORDS_DOMAIN_BOUNDARY.md](docs/RECORDS_DO
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Expected optional integrations:
|
Implemented optional integrations:
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- dms
|
- cases
|
||||||
- docs
|
- forms runtime
|
||||||
- policy
|
- decisions
|
||||||
|
- approvals
|
||||||
- audit
|
- audit
|
||||||
|
- search
|
||||||
|
|
||||||
|
Planned optional integrations include:
|
||||||
|
|
||||||
|
- Campaigns, Postbox, and Reporting
|
||||||
|
- target-tested DMS/archive providers
|
||||||
|
- policy
|
||||||
- transparency
|
- transparency
|
||||||
|
|
||||||
|
## Current Boundary
|
||||||
|
|
||||||
|
The current kernel supports planned/open records, exact filing, closure,
|
||||||
|
retention calculation, holds, appraisal, independent disposition approval,
|
||||||
|
archive-neutral packaging, recovery evidence, and a clearly marked transfer
|
||||||
|
simulation. The simulation never claims archival custody. Restricted
|
||||||
|
per-record grants, real target conformance, destructive execution, and real
|
||||||
|
archive effects remain explicit later work packages.
|
||||||
|
|
||||||
|
## Reference Journey
|
||||||
|
|
||||||
|
The executable resident-parking-permit fixture proves equivalent assisted and
|
||||||
|
authenticated digital eAkten. It files the exact form, attachment, case and
|
||||||
|
representation, decision, delivery, and correction revisions; preserves
|
||||||
|
authority, purpose, policy, chronology, and custody evidence; exercises hold
|
||||||
|
and independent approval; and verifies search, temporal reconstruction, and
|
||||||
|
source/package integrity after a SQLite backup/restore round trip. The Records
|
||||||
|
workspace keeps the selected `recordId` in the URL so an authorized actor can
|
||||||
|
resume the same record and inspect the complete evidence chain.
|
||||||
|
|
||||||
|
The detailed target and implementation sequence are documented in
|
||||||
|
[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md).
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
From the core checkout:
|
From the workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-records
|
||||||
./.venv/bin/python -m pip install -e ../govoplan-records
|
/mnt/DATA/git/govoplan/.venv/bin/python -m pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused manifest verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-records
|
cd /mnt/DATA/git/govoplan-records
|
||||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gitea Workflow
|
## Gitea Workflow
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 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 cross-module reference journey from Records #8 is executable: equivalent
|
||||||
|
assisted and authenticated digital permit records preserve exact form,
|
||||||
|
attachment, case/representation, decision, delivery, and correction evidence;
|
||||||
|
exercise closure, retention, hold, approval, and simulated transfer; and prove
|
||||||
|
search and temporal reconstruction after database backup/restore. The
|
||||||
|
remaining boundary is intentionally visible rather than implied: Records #7
|
||||||
|
requires target testing of the selected d.velop d3 archive endpoint and its
|
||||||
|
conformance profile. Restricted per-record access is implemented through
|
||||||
|
revisioned, effective-dated subject/action/purpose grants. Current grants
|
||||||
|
govern historical reads and replays, `records.search` is an explicit purpose,
|
||||||
|
and the last active management grant cannot be revoked while a record remains
|
||||||
|
restricted. 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.
|
||||||
|
- Restricted access is narrower than ordinary module read permission. It
|
||||||
|
requires a current account, membership, group, role, function-assignment, or
|
||||||
|
delegation grant whose action and exact allowed purpose match the request.
|
||||||
|
Manage implies write/read and write implies read; all other matches are
|
||||||
|
exact and fail closed without revealing the record.
|
||||||
|
- 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 files the digital and assisted variants of the
|
||||||
|
resident-parking-permit service-to-decision journey into equivalent records.
|
||||||
|
Its fixture and regression test prove search, historical reconstruction, hold,
|
||||||
|
independent approval, simulated transfer, restore, current access explanation,
|
||||||
|
source digest revalidation, and transfer-manifest integrity. Its UI contract
|
||||||
|
pins URL-based resume, exact-source filing presets, governance context,
|
||||||
|
evidence semantics, lifecycle actions, and recovery inspection.
|
||||||
@@ -28,19 +28,66 @@ Records management for file plans, records classification, retention schedules,
|
|||||||
- audit
|
- audit
|
||||||
- transparency
|
- transparency
|
||||||
|
|
||||||
## Seed State
|
## Implemented State
|
||||||
|
|
||||||
The current repository state is intentionally small:
|
The native kernel currently provides:
|
||||||
|
|
||||||
- module manifest and entry point
|
- versioned file-plan nodes and record classes;
|
||||||
- tenant-level permission definitions
|
- stable record identities, immutable OCC-guarded revisions, volumes, exact
|
||||||
- manager and viewer role templates
|
filed items, and chronology;
|
||||||
- documentation topic describing the module boundary
|
- independent valid and recorded time with current/at/all temporal reads;
|
||||||
- Gitea issue workflow templates
|
- tenant, purpose, actor/capacity, source authority, institutional context,
|
||||||
- manifest contract test
|
provenance, and idempotency fields;
|
||||||
|
- provider-neutral exact-source capabilities implemented by Files versions,
|
||||||
|
Cases revisions, Forms Runtime submissions, and Decisions 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.
|
||||||
|
- an executable assisted/digital resident-parking-permit reference package
|
||||||
|
that proves equivalent filing semantics, URL resume, search, temporal
|
||||||
|
reconstruction, and evidence integrity after database backup/restore.
|
||||||
|
- revisioned, effective-dated restricted-record grants for accounts,
|
||||||
|
memberships, groups, roles, function assignments, and delegations, with
|
||||||
|
exact read/write/manage actions and allowed purposes. Current grants govern
|
||||||
|
historical reads and idempotent replays; search requires `records.search`.
|
||||||
|
|
||||||
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.
|
A target-tested archive adapter and any destructive effect remain deliberately unimplemented;
|
||||||
|
approved destruction is only a pending lifecycle state.
|
||||||
|
|
||||||
## First Implementation Slice
|
## Data-subject requests
|
||||||
|
|
||||||
Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.
|
Records publishes `privacy.dsar.records`. A record-subject search must include
|
||||||
|
an exact record, revision, item, volume, chronology, hold, disposition,
|
||||||
|
transfer-package, or authoritative source-module reference. A canonical
|
||||||
|
account, identity, or membership match identifies staff accountability
|
||||||
|
activity only; creating, filing, reviewing, or transferring an eAkte does not
|
||||||
|
make that staff member the subject of its contents.
|
||||||
|
|
||||||
|
The provider exports bounded record identity, revision, filing, chronology,
|
||||||
|
hold, disposition, transfer, and the subject's own restricted-access grant
|
||||||
|
lifecycle metadata. It excludes source
|
||||||
|
content, record snapshots and search text, opaque institutional contexts and
|
||||||
|
payloads, grant reasons, digests, replay keys, launch URLs, approval identifiers, archive
|
||||||
|
manifests and receipts, and unrelated records. Exact source content remains in
|
||||||
|
the source owner's DSAR provider. Record revisions and lifecycle evidence are
|
||||||
|
immutable retention evidence; the current record fact receives a
|
||||||
|
non-executable manual-review action. Actual correction, closure, appraisal,
|
||||||
|
hold, disposition, or transfer must use the governed eAkte lifecycle.
|
||||||
|
|
||||||
|
## Next Boundary Slice
|
||||||
|
|
||||||
|
Complete one target-tested archive provider without moving source-module
|
||||||
|
ownership into Records. The executable reference journey
|
||||||
|
already covers assisted/digital intake, decision, filing, hold, restore,
|
||||||
|
search, and archive simulation; it is evidence for the native boundary, not a
|
||||||
|
claim of target archive conformance or transferred custody.
|
||||||
|
|
||||||
|
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",
|
"name": "@govoplan/records",
|
||||||
"version": "0.1.8",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Records platform module seed.",
|
"description": "GovOPlaN eAkte and institutional records module.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-records"
|
name = "govoplan-records"
|
||||||
version = "0.1.8"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN Records platform module seed."
|
description = "GovOPlaN eAkte and institutional records module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.18",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[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,666 @@
|
|||||||
|
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 RecordAccessGrantRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "record_access_grant_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "grant_id", "revision", name="uq_record_access_grant_revision"
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "idempotency_key", name="uq_record_access_grant_idempotency"
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_record_access_grant_current",
|
||||||
|
"tenant_id",
|
||||||
|
"record_id",
|
||||||
|
"status",
|
||||||
|
"superseded_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_record_access_grant_subject",
|
||||||
|
"tenant_id",
|
||||||
|
"subject_type",
|
||||||
|
"subject_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)
|
||||||
|
grant_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_access_grant_revisions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
actions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_purposes: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
valid_from: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
valid_to: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=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 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__ = [
|
||||||
|
"RecordAccessGrantRevision",
|
||||||
|
"RecordChronologyEntry",
|
||||||
|
"RecordClassRevision",
|
||||||
|
"RecordFilePlanRevision",
|
||||||
|
"RecordHoldRevision",
|
||||||
|
"RecordIdentity",
|
||||||
|
"RecordItem",
|
||||||
|
"RecordDispositionRevision",
|
||||||
|
"RecordRevision",
|
||||||
|
"RecordTransferPackageRevision",
|
||||||
|
"RecordVolumeRevision",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,77 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.information_governance import (
|
||||||
|
InformationGovernanceDimension,
|
||||||
|
ModuleInformationGovernance,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
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.dsar_provider import (
|
||||||
|
RECORDS_DSAR_CAPABILITY,
|
||||||
|
RecordsDsarProvider,
|
||||||
|
)
|
||||||
|
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_ID = "records"
|
||||||
MODULE_NAME = "Records"
|
MODULE_NAME = "Records"
|
||||||
MODULE_VERSION = "0.1.8"
|
MODULE_VERSION = "0.1.23"
|
||||||
READ_SCOPE = "records:workspace:read"
|
READ_SCOPE = "records:workspace:read"
|
||||||
WRITE_SCOPE = "records:workspace:write"
|
WRITE_SCOPE = "records:workspace:write"
|
||||||
ADMIN_SCOPE = "records:workspace:admin"
|
ADMIN_SCOPE = "records:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"files",
|
"files",
|
||||||
|
"cases",
|
||||||
|
"forms_runtime",
|
||||||
|
"decisions",
|
||||||
|
"campaigns",
|
||||||
|
"postbox",
|
||||||
|
"reporting",
|
||||||
"dms",
|
"dms",
|
||||||
"docs",
|
"docs",
|
||||||
"policy",
|
"policy",
|
||||||
|
"approvals",
|
||||||
"audit",
|
"audit",
|
||||||
"transparency",
|
"transparency",
|
||||||
|
"search",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,67 +89,731 @@ 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 _dsar_provider(context: ModuleContext) -> RecordsDsarProvider:
|
||||||
|
del context
|
||||||
|
return RecordsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
active_restricted_grants = (
|
||||||
|
session.query(record_models.RecordAccessGrantRevision)
|
||||||
|
.filter(
|
||||||
|
record_models.RecordAccessGrantRevision.tenant_id == tenant_id,
|
||||||
|
record_models.RecordAccessGrantRevision.superseded_at.is_(None),
|
||||||
|
record_models.RecordAccessGrantRevision.status == "active",
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"records": records,
|
||||||
|
"open_records": open_records,
|
||||||
|
"active_holds": active_holds,
|
||||||
|
"pending_dispositions": pending_dispositions,
|
||||||
|
"active_restricted_grants": active_restricted_grants,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View records workspace", "Read records records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage records workspace", "Create and update records records and workflow state."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."),
|
"View records workspace",
|
||||||
|
"Read currently authorized records, contents, chronology, and file-plan context.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage records workspace",
|
||||||
|
"Create and revise records, create volumes, and file exact source revisions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer records workspace",
|
||||||
|
"Version file-plan nodes and record classes and administer Records configuration.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="records_manager",
|
slug="records_manager",
|
||||||
name="Records manager",
|
name="Records manager",
|
||||||
description="Manage records records and workflow state.",
|
description="Create, revise, structure, and file content into records.",
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="records_viewer",
|
slug="records_viewer",
|
||||||
name="Records viewer",
|
name="Records viewer",
|
||||||
description="Read records records and workflow context.",
|
description="Read records and their governed chronology.",
|
||||||
permissions=(READ_SCOPE,),
|
permissions=(READ_SCOPE,),
|
||||||
),
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="records_administrator",
|
||||||
|
name="Records administrator",
|
||||||
|
description="Configure file plans and record classes and manage records.",
|
||||||
|
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id="records.data-subject-requests",
|
||||||
title=f"{MODULE_NAME} module boundary",
|
title="eAkte data-subject requests",
|
||||||
summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.",
|
summary="Export minimized record metadata while preserving retention, hold, disposition, and archive evidence.",
|
||||||
body=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"Records correlates exact record, revision, item, volume, chronology, hold, disposition, transfer-package, and provider-owned source references inside one tenant. Account, identity, and membership selectors expose staff accountability attribution and the subject's own restricted-record grants but never imply that the staff member is the subject of a record. A record-subject export therefore needs an authoritative record or source linkage. "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"The export includes bounded record, filing, chronology, hold, disposition, transfer, and access-grant lifecycle metadata. Source content, snapshots, search text, opaque contexts and payloads, grant reasons, hashes, replay keys, launch URLs, archive manifests and receipts, approval identifiers, and unrelated records are excluded. Immutable evidence is retained. The current record fact receives non-executable manual review and can change only through the governed eAkte lifecycle."
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||||
|
order=90,
|
||||||
|
related_modules=(
|
||||||
|
"core",
|
||||||
|
"cases",
|
||||||
|
"files",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"approvals",
|
||||||
|
"dms",
|
||||||
),
|
),
|
||||||
layer="available",
|
|
||||||
documentation_types=("admin",),
|
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
|
||||||
order=100,
|
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Repository domain boundary",
|
label="Records domain boundary",
|
||||||
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"kind": "reference",
|
||||||
"domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'],
|
"help_contexts": [
|
||||||
"first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.",
|
"records.data-subject-requests",
|
||||||
|
"records.lifecycle",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen zur eAkte",
|
||||||
|
"summary": (
|
||||||
|
"Minimierte Aktenmetadaten ausgeben und dabei Aufbewahrungs-, Sperr-, Aussonderungs- und Archivnachweise bewahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Records gleicht innerhalb eines Mandanten exakte Verweise auf Akte, Revision, Eintrag, Band, Chronologie, "
|
||||||
|
"Sperre, Aussonderung, Übergabepaket und anbietergeführte Quellen ab. Konto-, Identitäts- und "
|
||||||
|
"Mitgliedschaftsselektoren zeigen Verantwortungszuordnungen von Beschäftigten und deren eigene Freigaben für besonders geschützte Akten, bedeuten aber niemals, dass die "
|
||||||
|
"beschäftigte Person Gegenstand einer Akte ist. Eine Auskunft zur aktenbetroffenen Person erfordert deshalb eine "
|
||||||
|
"führende Akten- oder Quellverknüpfung. Die Ausgabe enthält begrenzte Metadaten zu Akte, Veraktung, Chronologie, "
|
||||||
|
"Sperre, Aussonderung, Übergabe und Zugriffsfreigaben. Quellinhalt, Schnappschüsse, Suchtext, undurchsichtige Kontexte und Nutzdaten, Freigabebegründungen, "
|
||||||
|
"Prüfsummen, Wiederholungsschlüssel, Start-URLs, Archivmanifeste und -belege, Genehmigungskennungen und fremde "
|
||||||
|
"Akten bleiben ausgeschlossen. Unveränderliche Nachweise werden aufbewahrt. Der aktuelle Aktenfakt erhält eine "
|
||||||
|
"nicht ausführbare manuelle Prüfung und kann nur über den gesteuerten eAkte-Lebenszyklus verändert werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="records.workspace",
|
||||||
|
title="eAkte workspace",
|
||||||
|
summary="Create and browse institutional records, their exact filed items, and chronology.",
|
||||||
|
body=(
|
||||||
|
"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. The selected record is preserved "
|
||||||
|
"as a recordId URL parameter so an authorized actor can resume the same evidence view. Restricted "
|
||||||
|
"records additionally require a current purpose-bound object grant for every read and mutation."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
|
order=100,
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="eAkte architecture",
|
||||||
|
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "eAkte-Arbeitsbereich",
|
||||||
|
"summary": "Institutionelle Akten, exakt veraktete Objekte und die Chronologie anlegen und einsehen.",
|
||||||
|
"body": (
|
||||||
|
"Records verwaltet die stabile Aktenidentität, Aktenplanklassifikation, unveränderliche "
|
||||||
|
"Revisionen, Bände, Veraktungsentscheidungen und die Chronologie. Dateien und andere "
|
||||||
|
"Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller "
|
||||||
|
"Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale "
|
||||||
|
"Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung "
|
||||||
|
"gilt stets weiter. Die ausgewählte Akte wird als recordId in der URL bewahrt. Besonders "
|
||||||
|
"geschützte Akten erfordern zusätzlich für jeden Lese- und Änderungsvorgang eine aktuelle "
|
||||||
|
"zweckgebundene Objektfreigabe."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"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.restricted-access",
|
||||||
|
title="Purpose-bound restricted record access",
|
||||||
|
summary="Grant effective-dated record access to explicit institutional subjects and purposes.",
|
||||||
|
body=(
|
||||||
|
"A restricted record is omitted unless the current account, membership, group, role, function assignment, "
|
||||||
|
"or delegation has an active object grant for the requested read, write, or manage action and the exact "
|
||||||
|
"declared purpose. Manage implies write and read; write implies read. The same current grant policy governs "
|
||||||
|
"historical views and idempotent replays. Search uses the separate purpose records.search. Creating a "
|
||||||
|
"restricted record establishes the creator as its first manager, and the final active management grant "
|
||||||
|
"cannot be revoked while the record remains restricted. Administrators should grant the narrowest subject, "
|
||||||
|
"actions, purposes, and validity interval that the assignment requires."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "records_manager", "module_admin", "auditor"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
|
order=105,
|
||||||
|
related_modules=("access", "search", "policy", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Zweckgebundener Zugriff auf besonders geschützte Akten",
|
||||||
|
"summary": "Zeitlich wirksame Aktenzugriffe für eindeutige institutionelle Subjekte und Zwecke vergeben.",
|
||||||
|
"body": (
|
||||||
|
"Eine besonders geschützte Akte wird nur angezeigt, wenn für das aktuelle Konto, die Mitgliedschaft, "
|
||||||
|
"Gruppe, Rolle, Funktionszuordnung oder Delegation eine aktive Objektfreigabe für die angeforderte "
|
||||||
|
"Lese-, Schreib- oder Verwaltungsaktion und den exakt erklärten Zweck besteht. Verwalten umfasst "
|
||||||
|
"Schreiben und Lesen; Schreiben umfasst Lesen. Dieselbe aktuelle Freigabelage gilt für historische "
|
||||||
|
"Ansichten und idempotente Wiederholungen. Die Suche verwendet den eigenen Zweck records.search. "
|
||||||
|
"Beim Anlegen einer besonders geschützten Akte wird die anlegende Person als erste Verwaltungsperson "
|
||||||
|
"eingetragen. Solange die Akte geschützt bleibt, kann die letzte aktive Verwaltungsfreigabe nicht "
|
||||||
|
"entzogen werden. Administratoren sollen Subjekt, Aktionen, Zwecke und Gültigkeitszeitraum so eng wie "
|
||||||
|
"für die Aufgabe erforderlich festlegen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"records.restricted-access",
|
||||||
|
"records.field.access-mode",
|
||||||
|
"records.field.initial-purposes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="records.filing",
|
||||||
|
title="Exact record filing",
|
||||||
|
summary="File immutable Files, Cases, Forms Runtime, or Decisions 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",
|
||||||
|
"forms_runtime",
|
||||||
|
"decisions",
|
||||||
|
"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={
|
||||||
|
"kind": "reference",
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="records.reference-journey",
|
||||||
|
title="Assisted and digital eAkte reference journey",
|
||||||
|
summary="Reconstruct the resident-parking-permit evidence chain and verify recovery without implying archive custody.",
|
||||||
|
body=(
|
||||||
|
"Equivalent assisted and authenticated digital resident-parking-permit records preserve exact "
|
||||||
|
"form, attachment, case and representation, formal decision, delivery receipt, and correction "
|
||||||
|
"revisions. The record view explains each source version, authority mode, relationship, filing "
|
||||||
|
"purpose, institutional context, legal basis, retention policy, chronology, hold, independent "
|
||||||
|
"approval, and transfer result. The selected recordId remains in the URL for authorized resume. "
|
||||||
|
"Automated evidence restores a database backup, searches and temporally reconstructs both records, "
|
||||||
|
"and revalidates source digests and the archive-neutral manifest. A simulated receipt explicitly "
|
||||||
|
"states that custody was not transferred; real archive conformance remains target-specific."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||||
|
order=130,
|
||||||
|
related_modules=(
|
||||||
|
"forms_runtime",
|
||||||
|
"files",
|
||||||
|
"cases",
|
||||||
|
"decisions",
|
||||||
|
"approvals",
|
||||||
|
"search",
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="eAkte architecture",
|
||||||
|
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Referenzablauf für assistierte und digitale eAkten",
|
||||||
|
"summary": "Die Nachweiskette des Anwohnerparkausweises rekonstruieren und die Wiederherstellung prüfen, ohne Archivverwahrung zu behaupten.",
|
||||||
|
"body": (
|
||||||
|
"Gleichwertige assistierte und authentifizierte digitale Akten zum Anwohnerparkausweis "
|
||||||
|
"bewahren exakte Revisionen von Formular, Anlage, Vorgang und Vertretung, förmlicher "
|
||||||
|
"Entscheidung, Zustellbeleg und Korrektur. Die Aktenansicht erläutert Quellversion, "
|
||||||
|
"Autoritätsmodus, Beziehung, Veraktungszweck, institutionellen Kontext, Rechtsgrundlage, "
|
||||||
|
"Aufbewahrungsrichtlinie, Chronologie, Sperre, unabhängige Freigabe und Übergabeergebnis. "
|
||||||
|
"Die ausgewählte recordId bleibt zum berechtigten Fortsetzen in der URL. Automatisierte "
|
||||||
|
"Nachweise stellen eine Datenbanksicherung wieder her, suchen und rekonstruieren beide "
|
||||||
|
"Akten temporal und prüfen Quellprüfsummen sowie das archivneutrale Manifest erneut. Ein "
|
||||||
|
"simulierter Beleg stellt ausdrücklich klar, dass keine Verwahrung übertragen wurde."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"records.governance-context",
|
||||||
|
"records.record-items",
|
||||||
|
"records.chronology",
|
||||||
|
"records.lifecycle",
|
||||||
|
"records.lifecycle.recovery",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/records",
|
||||||
|
label="Records",
|
||||||
|
icon="archive",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=47,
|
||||||
|
surface_id="records.navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/records-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/records",
|
||||||
|
component="RecordsPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=47,
|
||||||
|
surface_id="records.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/records",
|
||||||
|
label="Records",
|
||||||
|
icon="archive",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=47,
|
||||||
|
surface_id="records.navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="records-documents",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.records_documents",
|
||||||
|
icon="folder",
|
||||||
|
description="i18n:govoplan-core.product_area.records_documents_description",
|
||||||
|
surface_ids=("records.navigation", "records.workspace"),
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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"),
|
||||||
|
ModuleInterfaceProvider(name=RECORDS_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_RECORDS_FILING: _records_registry,
|
||||||
|
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
|
||||||
|
RECORDS_DSAR_CAPABILITY: _dsar_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",
|
||||||
|
),
|
||||||
|
RECORDS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Records data-subject request provider",
|
||||||
|
summary="Finds minimized eAkte and operator-attribution evidence without bypassing record retention.",
|
||||||
|
contract_version="0.1.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.RecordAccessGrantRevision,
|
||||||
|
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.RecordAccessGrantRevision,
|
||||||
|
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,
|
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="enforced",
|
||||||
|
object_types=("record", "record_item", "record_access_grant"),
|
||||||
|
evidence=(
|
||||||
|
"src/govoplan_records/backend/service.py",
|
||||||
|
"src/govoplan_records/backend/search_source.py",
|
||||||
|
"tests/test_records.py",
|
||||||
|
"tests/test_search_source.py",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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=(
|
||||||
|
"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,97 @@
|
|||||||
|
"""Add purpose-bound restricted-record access grants.
|
||||||
|
|
||||||
|
Revision ID: 8a6c4e2f1b3d
|
||||||
|
Revises: 7f5b3d9a2c1e
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "8a6c4e2f1b3d"
|
||||||
|
down_revision = "7f5b3d9a2c1e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"record_access_grant_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("grant_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("subject_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("actions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_purposes", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
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_access_grant_revisions.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"grant_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_record_access_grant_revision",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_record_access_grant_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"grant_id",
|
||||||
|
"record_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"status",
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
"valid_from",
|
||||||
|
"valid_to",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"changed_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_record_access_grant_revisions_{column}"),
|
||||||
|
"record_access_grant_revisions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_access_grant_current",
|
||||||
|
"record_access_grant_revisions",
|
||||||
|
["tenant_id", "record_id", "status", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_record_access_grant_subject",
|
||||||
|
"record_access_grant_revisions",
|
||||||
|
["tenant_id", "subject_type", "subject_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("record_access_grant_revisions")
|
||||||
@@ -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,782 @@
|
|||||||
|
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,
|
||||||
|
RecordAccessGrantCreateRequest,
|
||||||
|
RecordAccessGrantListResponse,
|
||||||
|
RecordAccessGrantRevokeRequest,
|
||||||
|
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),
|
||||||
|
purpose: 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,
|
||||||
|
purpose=purpose,
|
||||||
|
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),
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
|
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,
|
||||||
|
purpose=purpose,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
purpose: str = Query(min_length=1, max_length=255),
|
||||||
|
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, purpose=purpose
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RecordStoreError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{record_id}/access-grants",
|
||||||
|
response_model=RecordAccessGrantListResponse,
|
||||||
|
)
|
||||||
|
def api_list_access_grants(
|
||||||
|
record_id: str,
|
||||||
|
purpose: str = Query(min_length=1, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> RecordAccessGrantListResponse:
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
return RecordAccessGrantListResponse(
|
||||||
|
grants=records.list_access_grants(
|
||||||
|
session, principal, record_id=record_id, purpose=purpose
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RecordStoreError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{record_id}/access-grants",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_access_grant(
|
||||||
|
record_id: str,
|
||||||
|
payload: RecordAccessGrantCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: records.create_access_grant(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
record_id=record_id,
|
||||||
|
payload=payload.model_dump(mode="python"),
|
||||||
|
),
|
||||||
|
principal=principal,
|
||||||
|
operation_type="record.access_grant.create",
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
request=payload.model_dump(mode="json"),
|
||||||
|
resource_type="record_access_grant",
|
||||||
|
resource_id=record_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{record_id}/access-grants/{grant_id}/revoke",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
)
|
||||||
|
def api_revoke_access_grant(
|
||||||
|
record_id: str,
|
||||||
|
grant_id: str,
|
||||||
|
payload: RecordAccessGrantRevokeRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
return _write(
|
||||||
|
session,
|
||||||
|
lambda: records.revoke_access_grant(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
record_id=record_id,
|
||||||
|
grant_id=grant_id,
|
||||||
|
payload=payload.model_dump(mode="python"),
|
||||||
|
),
|
||||||
|
principal=principal,
|
||||||
|
operation_type="record.access_grant.revoke",
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
request={"grant_id": grant_id, **payload.model_dump(mode="json")},
|
||||||
|
resource_type="record_access_grant",
|
||||||
|
resource_id=grant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
if payload.access_mode is not None:
|
||||||
|
_require(principal, ADMIN_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,372 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_purpose_values(values: list[str], field_name: str) -> None:
|
||||||
|
normalized = [item.strip() for item in values]
|
||||||
|
if any(not item or len(item) > 255 for item in normalized):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must contain non-empty values up to 255 characters"
|
||||||
|
)
|
||||||
|
if len(normalized) != len(set(normalized)):
|
||||||
|
raise ValueError(f"{field_name} must contain unique values")
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
initial_purposes: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
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")
|
||||||
|
_validate_purpose_values(self.initial_purposes, "initial_purposes")
|
||||||
|
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
|
||||||
|
initial_purposes: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
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")
|
||||||
|
_validate_purpose_values(self.initial_purposes, "initial_purposes")
|
||||||
|
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 RecordAccessGrantCreateRequest(StrictModel):
|
||||||
|
grant_id: str | None = Field(default=None, max_length=255)
|
||||||
|
subject_type: Literal[
|
||||||
|
"account",
|
||||||
|
"membership",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function_assignment",
|
||||||
|
"delegation",
|
||||||
|
]
|
||||||
|
subject_id: str = Field(min_length=1, max_length=255)
|
||||||
|
actions: list[Literal["read", "write", "manage"]] = Field(
|
||||||
|
min_length=1, max_length=3
|
||||||
|
)
|
||||||
|
allowed_purposes: list[str] = Field(min_length=1, max_length=100)
|
||||||
|
reason: str = Field(min_length=1, max_length=2_000)
|
||||||
|
purpose: str = Field(min_length=1, max_length=255)
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_to: datetime | None = None
|
||||||
|
recorded_at: datetime
|
||||||
|
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_grant(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")
|
||||||
|
if len(self.actions) != len(set(self.actions)):
|
||||||
|
raise ValueError("actions must be unique")
|
||||||
|
_validate_purpose_values(self.allowed_purposes, "allowed_purposes")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RecordAccessGrantRevokeRequest(StrictModel):
|
||||||
|
expected_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 RecordAccessGrantListResponse(StrictModel):
|
||||||
|
grants: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"RecordAccessGrantCreateRequest",
|
||||||
|
"RecordAccessGrantListResponse",
|
||||||
|
"RecordAccessGrantRevokeRequest",
|
||||||
|
"RecordArchiveProviderResponse",
|
||||||
|
"RecordCatalogResponse",
|
||||||
|
"RecordClassWriteRequest",
|
||||||
|
"RecordCloseRequest",
|
||||||
|
"RecordCreateRequest",
|
||||||
|
"RecordDetailResponse",
|
||||||
|
"RecordDispositionCreateRequest",
|
||||||
|
"RecordDispositionFinalizeRequest",
|
||||||
|
"RecordDispositionWithdrawRequest",
|
||||||
|
"RecordHoldCreateRequest",
|
||||||
|
"RecordHoldReleaseRequest",
|
||||||
|
"RecordItemCreateRequest",
|
||||||
|
"RecordLifecycleActionRequest",
|
||||||
|
"RecordListResponse",
|
||||||
|
"RecordRecoveryStatusResponse",
|
||||||
|
"RecordSourceProviderResponse",
|
||||||
|
"RecordTransferDispatchRequest",
|
||||||
|
"RecordTransferPackageCreateRequest",
|
||||||
|
"RecordUpdateRequest",
|
||||||
|
"RecordVolumeCreateRequest",
|
||||||
|
]
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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
|
||||||
|
from govoplan_records.backend.service import _eligible_record_ids
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
_eligible_record_ids(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
action="read",
|
||||||
|
purpose="records.search",
|
||||||
|
).intersection(resource_ids)
|
||||||
|
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='')}&purpose=records.search"
|
||||||
|
),
|
||||||
|
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
+193
@@ -0,0 +1,193 @@
|
|||||||
|
{
|
||||||
|
"id": "resident-parking-permit-assisted-record",
|
||||||
|
"description": "A completed assisted resident parking permit service is filed as an eAkte, closed, held, appraised, independently approved, restored from backup, and exercised through the archive simulation boundary.",
|
||||||
|
"record": {
|
||||||
|
"record_id": "record-1",
|
||||||
|
"record_number": "2026/0001",
|
||||||
|
"class_id": "class-permit",
|
||||||
|
"title": "Permit application Ada Example"
|
||||||
|
},
|
||||||
|
"institutional_context": {
|
||||||
|
"service_id": "resident-parking-permit",
|
||||||
|
"case_id": "case-parking-2026-0001",
|
||||||
|
"applicant_party_id": "party-ada-example",
|
||||||
|
"representative_party_id": "party-alex-example",
|
||||||
|
"representation_id": "representation-2026-0001",
|
||||||
|
"authority_id": "authority-mobility-berlin",
|
||||||
|
"responsible_function_id": "function-parking-permits",
|
||||||
|
"mandate_id": "mandate-parking-permits-2026",
|
||||||
|
"purpose": "decide the resident parking permit application",
|
||||||
|
"legal_basis": "StVG section 6 and StVO section 45",
|
||||||
|
"retention_policy_ref": "records-policy:parking-permit:v1"
|
||||||
|
},
|
||||||
|
"digital_equivalent": {
|
||||||
|
"record": {
|
||||||
|
"record_id": "record-digital",
|
||||||
|
"record_number": "2026/0002",
|
||||||
|
"class_id": "class-permit",
|
||||||
|
"title": "Permit application Dana Digital"
|
||||||
|
},
|
||||||
|
"institutional_context_overrides": {
|
||||||
|
"case_id": "case-parking-2026-0002",
|
||||||
|
"applicant_party_id": "party-dana-digital",
|
||||||
|
"representative_party_id": "party-dana-digital",
|
||||||
|
"representation_id": "self-representation-2026-0002"
|
||||||
|
},
|
||||||
|
"intake_source": {
|
||||||
|
"id": "digital-submission",
|
||||||
|
"source_module": "forms_runtime",
|
||||||
|
"resource_type": "form_submission_revision",
|
||||||
|
"resource_id": "submission-parking-2026-0002",
|
||||||
|
"source_revision": "revision-2-submitted",
|
||||||
|
"label": "Digital resident parking permit submission",
|
||||||
|
"authority_mode": "native_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 3968,
|
||||||
|
"relationship": "initiates",
|
||||||
|
"purpose": "document the submitted application",
|
||||||
|
"filing_reason": "The authenticated digital submission initiated the administrative procedure.",
|
||||||
|
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0002",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "application",
|
||||||
|
"channel": "digital",
|
||||||
|
"authenticated": true,
|
||||||
|
"form_definition_revision": "resident-parking-permit:v4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "assisted-submission",
|
||||||
|
"source_module": "forms_runtime",
|
||||||
|
"resource_type": "form_submission_revision",
|
||||||
|
"resource_id": "submission-parking-2026-0001",
|
||||||
|
"source_revision": "revision-3-submitted",
|
||||||
|
"label": "Assisted resident parking permit submission",
|
||||||
|
"authority_mode": "native_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"relationship": "initiates",
|
||||||
|
"purpose": "document the submitted application",
|
||||||
|
"filing_reason": "The assisted, read-back submission initiated the administrative procedure.",
|
||||||
|
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0001",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "application",
|
||||||
|
"channel": "assisted",
|
||||||
|
"read_back_confirmed": true,
|
||||||
|
"form_definition_revision": "resident-parking-permit:v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "application-attachment",
|
||||||
|
"source_module": "files",
|
||||||
|
"resource_type": "file_version",
|
||||||
|
"resource_id": "file-registration-certificate",
|
||||||
|
"source_revision": "version-2",
|
||||||
|
"label": "Vehicle registration certificate.pdf",
|
||||||
|
"authority_mode": "external_authoritative",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"size_bytes": 245760,
|
||||||
|
"relationship": "supports",
|
||||||
|
"purpose": "document application eligibility",
|
||||||
|
"filing_reason": "The submitted certificate supports the applicant's vehicle eligibility.",
|
||||||
|
"launch_url": "/files?fileId=file-registration-certificate",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "application_attachment",
|
||||||
|
"malware_scan": "clean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "case-and-representation",
|
||||||
|
"source_module": "cases",
|
||||||
|
"resource_type": "case_revision",
|
||||||
|
"resource_id": "case-parking-2026-0001",
|
||||||
|
"source_revision": "revision-5-representation-verified",
|
||||||
|
"label": "Resident parking permit case with verified representation",
|
||||||
|
"authority_mode": "native_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 8192,
|
||||||
|
"relationship": "governs",
|
||||||
|
"purpose": "document case ownership and representation",
|
||||||
|
"filing_reason": "The case revision identifies the applicant, representative, authority, and verified representation used for the decision.",
|
||||||
|
"launch_url": "/cases/case-parking-2026-0001",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "case_context",
|
||||||
|
"representation_id": "representation-2026-0001",
|
||||||
|
"representation_status": "verified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "formal-decision",
|
||||||
|
"source_module": "decisions",
|
||||||
|
"resource_type": "decision_revision",
|
||||||
|
"resource_id": "decision-parking-2026-0001",
|
||||||
|
"source_revision": "revision-2-issued",
|
||||||
|
"label": "Resident parking permit decision",
|
||||||
|
"authority_mode": "native_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 6144,
|
||||||
|
"relationship": "decides",
|
||||||
|
"purpose": "document the formal administrative outcome",
|
||||||
|
"filing_reason": "The issued decision records the competent authority, legal basis, outcome, and reasons.",
|
||||||
|
"launch_url": "/decisions/decision-parking-2026-0001",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "formal_decision",
|
||||||
|
"outcome": "approved",
|
||||||
|
"legal_basis": "StVG section 6 and StVO section 45"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "delivery-receipt",
|
||||||
|
"source_module": "files",
|
||||||
|
"resource_type": "file_version",
|
||||||
|
"resource_id": "file-postbox-delivery-receipt",
|
||||||
|
"source_revision": "version-1",
|
||||||
|
"label": "Trusted postbox delivery receipt.json",
|
||||||
|
"authority_mode": "external_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 2048,
|
||||||
|
"relationship": "proves_delivery",
|
||||||
|
"purpose": "document delivery of the formal decision",
|
||||||
|
"filing_reason": "The immutable receipt proves delivery of the issued decision to the represented applicant's trusted postbox.",
|
||||||
|
"launch_url": "/files?fileId=file-postbox-delivery-receipt",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "delivery_receipt",
|
||||||
|
"decision_revision": "revision-2-issued",
|
||||||
|
"delivery_channel": "trusted_postbox",
|
||||||
|
"delivery_status": "delivered"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "correction-evidence",
|
||||||
|
"source_module": "files",
|
||||||
|
"resource_type": "file_version",
|
||||||
|
"resource_id": "file-decision-correction-evidence",
|
||||||
|
"source_revision": "version-1",
|
||||||
|
"label": "Decision address correction evidence.json",
|
||||||
|
"authority_mode": "external_authoritative",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"size_bytes": 2304,
|
||||||
|
"relationship": "corrects",
|
||||||
|
"purpose": "preserve correction chronology without replacing prior evidence",
|
||||||
|
"filing_reason": "The correction evidence links the corrected delivery address to the original decision and receipt while preserving both.",
|
||||||
|
"launch_url": "/files?fileId=file-decision-correction-evidence",
|
||||||
|
"metadata": {
|
||||||
|
"evidence_role": "correction",
|
||||||
|
"corrects_source_id": "delivery-receipt",
|
||||||
|
"previous_evidence_retained": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"equivalent_record_count": 2,
|
||||||
|
"filed_item_count": 6,
|
||||||
|
"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,
|
||||||
|
"restored_search_query": "Ada Example"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_records.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_german_workflow_and_reference_are_complete() -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
assert len(topics) == 5
|
||||||
|
for topic in topics.values():
|
||||||
|
assert all(topic.translations["de"].get(key) for key in ("title", "summary", "body"))
|
||||||
|
workflow = topics["records.workspace"]
|
||||||
|
assert workflow.metadata["kind"] == "workflow"
|
||||||
|
assert workflow.conditions
|
||||||
|
assert topics["records.filing"].metadata["kind"] == "reference"
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_records.backend.db.models import (
|
||||||
|
RecordAccessGrantRevision,
|
||||||
|
RecordChronologyEntry,
|
||||||
|
RecordClassRevision,
|
||||||
|
RecordDispositionRevision,
|
||||||
|
RecordFilePlanRevision,
|
||||||
|
RecordHoldRevision,
|
||||||
|
RecordIdentity,
|
||||||
|
RecordItem,
|
||||||
|
RecordRevision,
|
||||||
|
RecordTransferPackageRevision,
|
||||||
|
RecordVolumeRevision,
|
||||||
|
)
|
||||||
|
from govoplan_records.backend.dsar_provider import (
|
||||||
|
RECORDS_DSAR_CAPABILITY,
|
||||||
|
RecordsDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_records.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: RecordsDsarProvider, *, active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (RECORDS_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "records"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("records",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "records"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != RECORDS_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordsDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = RecordsDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed_record_package()
|
||||||
|
self._seed_unrelated_records()
|
||||||
|
self._seed_operator_configuration()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed_record_package(self) -> None:
|
||||||
|
identity = RecordIdentity(
|
||||||
|
id="identity-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-1",
|
||||||
|
record_number="2026/0001",
|
||||||
|
created_by="clerk-1",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
RecordRevision(
|
||||||
|
id="revision-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=1,
|
||||||
|
class_id="class-permit",
|
||||||
|
file_plan_node_id="plan-permits",
|
||||||
|
title="Resident parking permit record",
|
||||||
|
description="Application by Ada Example",
|
||||||
|
state="retention_running",
|
||||||
|
source_authority_mode="native_authoritative",
|
||||||
|
access_mode="tenant",
|
||||||
|
purpose="decide resident parking permit",
|
||||||
|
classification="personal",
|
||||||
|
responsible_unit_id="unit-1",
|
||||||
|
responsible_function_id="function-permits",
|
||||||
|
external_reference={
|
||||||
|
"case_id": "case-1",
|
||||||
|
"account_id": "applicant-1",
|
||||||
|
"secret": "private-external-reference-do-not-export",
|
||||||
|
},
|
||||||
|
institutional_context={
|
||||||
|
"membership_id": "membership-applicant-1",
|
||||||
|
"secret": "private-context-do-not-export",
|
||||||
|
},
|
||||||
|
search_text="private-search-text-do-not-export",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
superseded_at=NOW + timedelta(minutes=1),
|
||||||
|
changed_by="clerk-1",
|
||||||
|
snapshot={"secret": "private-snapshot-do-not-export"},
|
||||||
|
),
|
||||||
|
RecordRevision(
|
||||||
|
id="revision-row-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=2,
|
||||||
|
previous_revision_id="revision-row-1",
|
||||||
|
class_id="class-permit",
|
||||||
|
file_plan_node_id="plan-permits",
|
||||||
|
title="Resident parking permit record",
|
||||||
|
description="Application by Ada Example",
|
||||||
|
state="retention_running",
|
||||||
|
source_authority_mode="native_authoritative",
|
||||||
|
access_mode="tenant",
|
||||||
|
purpose="decide resident parking permit",
|
||||||
|
classification="personal",
|
||||||
|
responsible_unit_id="unit-1",
|
||||||
|
responsible_function_id="function-permits",
|
||||||
|
external_reference={
|
||||||
|
"case_id": "case-1",
|
||||||
|
"account_id": "applicant-1",
|
||||||
|
"secret": "private-external-reference-do-not-export",
|
||||||
|
},
|
||||||
|
institutional_context={
|
||||||
|
"membership_id": "membership-applicant-1",
|
||||||
|
"secret": "private-context-do-not-export",
|
||||||
|
},
|
||||||
|
search_text="private-search-text-do-not-export",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
changed_by="clerk-1",
|
||||||
|
closed_at=NOW + timedelta(minutes=1),
|
||||||
|
retention_started_at=NOW + timedelta(minutes=1),
|
||||||
|
retention_due_at=NOW + timedelta(days=3650),
|
||||||
|
retention_rule={"secret": "private-rule-do-not-export"},
|
||||||
|
appraisal_state="appraised",
|
||||||
|
appraisal={"secret": "private-appraisal-do-not-export"},
|
||||||
|
snapshot={"secret": "private-snapshot-do-not-export"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordVolumeRevision(
|
||||||
|
id="volume-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
volume_id="volume-1",
|
||||||
|
record_id="record-1",
|
||||||
|
revision=1,
|
||||||
|
sequence=1,
|
||||||
|
label="Application",
|
||||||
|
state="closed",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
changed_by="clerk-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordItem(
|
||||||
|
id="item-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-1",
|
||||||
|
volume_id="volume-1",
|
||||||
|
sequence=1,
|
||||||
|
source_module="cases",
|
||||||
|
resource_type="case_revision",
|
||||||
|
resource_id="case-1",
|
||||||
|
source_revision="revision-2",
|
||||||
|
label="Permit case",
|
||||||
|
relationship="decision_basis",
|
||||||
|
filing_reason="The application belongs to this record.",
|
||||||
|
purpose="document the permit decision",
|
||||||
|
authority_mode="native_authoritative",
|
||||||
|
content_sha256="a" * 64,
|
||||||
|
content_type="application/json",
|
||||||
|
size_bytes=2048,
|
||||||
|
source_valid_from=NOW,
|
||||||
|
source_recorded_at=NOW,
|
||||||
|
launch_url="https://secret.example/do-not-export",
|
||||||
|
filed_at=NOW + timedelta(minutes=1),
|
||||||
|
filed_by="clerk-1",
|
||||||
|
actor_assignment_id="private-assignment-do-not-export",
|
||||||
|
actor_delegation_id="private-delegation-do-not-export",
|
||||||
|
institutional_context={"secret": "private-item-context-do-not-export"},
|
||||||
|
source_metadata={"secret": "private-source-data-do-not-export"},
|
||||||
|
filing_metadata={"secret": "private-filing-data-do-not-export"},
|
||||||
|
idempotency_key="private-item-key-do-not-export",
|
||||||
|
request_sha256="b" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordChronologyEntry(
|
||||||
|
id="chronology-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-1",
|
||||||
|
event_id="event-1",
|
||||||
|
event_type="record.item_filed",
|
||||||
|
record_revision=2,
|
||||||
|
summary="private-event-summary-do-not-export",
|
||||||
|
occurred_at=NOW + timedelta(minutes=1),
|
||||||
|
actor_id="clerk-1",
|
||||||
|
actor_assignment_id="private-assignment-do-not-export",
|
||||||
|
actor_delegation_id="private-delegation-do-not-export",
|
||||||
|
purpose="document the permit decision",
|
||||||
|
idempotency_key="private-event-key-do-not-export",
|
||||||
|
request_sha256="c" * 64,
|
||||||
|
institutional_context={"secret": "private-event-context-do-not-export"},
|
||||||
|
payload={"secret": "private-event-payload-do-not-export"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordHoldRevision(
|
||||||
|
id="hold-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
hold_id="hold-1",
|
||||||
|
record_id="record-1",
|
||||||
|
revision=1,
|
||||||
|
status="released",
|
||||||
|
reason="private-hold-reason-do-not-export",
|
||||||
|
authority="private-hold-authority-do-not-export",
|
||||||
|
scope={"secret": "private-hold-scope-do-not-export"},
|
||||||
|
effective_from=NOW + timedelta(minutes=2),
|
||||||
|
released_at=NOW + timedelta(minutes=3),
|
||||||
|
policy_refs=["private-policy-do-not-export"],
|
||||||
|
institutional_context={"secret": "private-hold-context-do-not-export"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
changed_by="clerk-1",
|
||||||
|
idempotency_key="private-hold-key-do-not-export",
|
||||||
|
request_sha256="d" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordDispositionRevision(
|
||||||
|
id="disposition-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
disposition_id="disposition-1",
|
||||||
|
record_id="record-1",
|
||||||
|
revision=1,
|
||||||
|
action="transfer",
|
||||||
|
status="approved",
|
||||||
|
reason="private-disposition-reason-do-not-export",
|
||||||
|
subject_revision=2,
|
||||||
|
subject_sha256="e" * 64,
|
||||||
|
consequence_preview={"secret": "private-preview-do-not-export"},
|
||||||
|
policy_refs=["private-policy-do-not-export"],
|
||||||
|
approval_request_id="private-approval-do-not-export",
|
||||||
|
proposed_by="clerk-1",
|
||||||
|
reviewed_by="reviewer-1",
|
||||||
|
reviewed_at=NOW + timedelta(minutes=4),
|
||||||
|
institutional_context={
|
||||||
|
"secret": "private-disposition-context-do-not-export"
|
||||||
|
},
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
idempotency_key="private-disposition-key-do-not-export",
|
||||||
|
request_sha256="f" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordTransferPackageRevision(
|
||||||
|
id="transfer-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
package_id="package-1",
|
||||||
|
record_id="record-1",
|
||||||
|
disposition_id="disposition-1",
|
||||||
|
revision=1,
|
||||||
|
record_revision=2,
|
||||||
|
provider_id="archive-1",
|
||||||
|
profile="xarchive",
|
||||||
|
status="accepted",
|
||||||
|
authority_mode="external_authoritative",
|
||||||
|
manifest={"secret": "private-manifest-do-not-export"},
|
||||||
|
manifest_sha256="1" * 64,
|
||||||
|
receipt={"secret": "private-receipt-do-not-export"},
|
||||||
|
receipt_sha256="2" * 64,
|
||||||
|
external_reference="private-archive-reference-do-not-export",
|
||||||
|
recovery_operation_id="private-recovery-id-do-not-export",
|
||||||
|
simulated=False,
|
||||||
|
institutional_context={
|
||||||
|
"secret": "private-transfer-context-do-not-export"
|
||||||
|
},
|
||||||
|
recorded_at=NOW + timedelta(minutes=5),
|
||||||
|
changed_by="clerk-1",
|
||||||
|
idempotency_key="private-transfer-key-do-not-export",
|
||||||
|
request_sha256="3" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_unrelated_records(self) -> None:
|
||||||
|
for suffix, tenant_id in (("2", "tenant-1"), ("3", "tenant-2")):
|
||||||
|
identity = RecordIdentity(
|
||||||
|
id=f"identity-row-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
record_id=f"record-{suffix}",
|
||||||
|
record_number=f"2026/000{suffix}",
|
||||||
|
created_by="other-clerk",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
RecordRevision(
|
||||||
|
id=f"unrelated-revision-row-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
record_id=f"record-{suffix}",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=1,
|
||||||
|
class_id="class-permit",
|
||||||
|
file_plan_node_id="plan-permits",
|
||||||
|
title=f"Unrelated record {suffix}",
|
||||||
|
description="unrelated-private-description",
|
||||||
|
state="open",
|
||||||
|
source_authority_mode="native_authoritative",
|
||||||
|
access_mode="tenant",
|
||||||
|
purpose="unrelated purpose",
|
||||||
|
external_reference={},
|
||||||
|
institutional_context={},
|
||||||
|
search_text="unrelated-private-search",
|
||||||
|
recorded_at=NOW,
|
||||||
|
changed_by="other-clerk",
|
||||||
|
snapshot={"secret": "unrelated-private-snapshot"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_operator_configuration(self) -> None:
|
||||||
|
self.session.add(
|
||||||
|
RecordFilePlanRevision(
|
||||||
|
id="plan-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
node_id="plan-permits",
|
||||||
|
revision=1,
|
||||||
|
code="10.20",
|
||||||
|
label="Permits",
|
||||||
|
active=True,
|
||||||
|
recorded_at=NOW,
|
||||||
|
institutional_context={"secret": "private-plan-context-do-not-export"},
|
||||||
|
changed_by="clerk-1",
|
||||||
|
idempotency_key="private-plan-key-do-not-export",
|
||||||
|
request_sha256="4" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
RecordClassRevision(
|
||||||
|
id="class-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
class_id="class-permit",
|
||||||
|
revision=1,
|
||||||
|
file_plan_node_id="plan-permits",
|
||||||
|
key="permit.application",
|
||||||
|
label="Permit application",
|
||||||
|
metadata_requirements=["private-metadata-rule-do-not-export"],
|
||||||
|
allowed_source_types=["cases:case_revision"],
|
||||||
|
retention_period_days=3650,
|
||||||
|
access_mode="tenant",
|
||||||
|
active=True,
|
||||||
|
recorded_at=NOW,
|
||||||
|
institutional_context={"secret": "private-class-context-do-not-export"},
|
||||||
|
changed_by="clerk-1",
|
||||||
|
idempotency_key="private-class-key-do-not-export",
|
||||||
|
request_sha256="5" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_direct_record_exports_minimized_complete_lifecycle(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="applicant-1",
|
||||||
|
external_references={"records.record": "record-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
resource_types = {record.resource_type for record in records}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"record_identity",
|
||||||
|
"record_revision",
|
||||||
|
"record_current_fact",
|
||||||
|
"record_volume_revision",
|
||||||
|
"record_item",
|
||||||
|
"record_chronology_entry",
|
||||||
|
"record_hold_revision",
|
||||||
|
"record_disposition_revision",
|
||||||
|
"record_transfer_package_revision",
|
||||||
|
}.issubset(resource_types)
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"record-2", json.dumps([record.to_dict() for record in records])
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
for secret in (
|
||||||
|
"private-snapshot-do-not-export",
|
||||||
|
"private-search-text-do-not-export",
|
||||||
|
"private-context-do-not-export",
|
||||||
|
"private-event-summary-do-not-export",
|
||||||
|
"private-event-payload-do-not-export",
|
||||||
|
"private-hold-reason-do-not-export",
|
||||||
|
"private-preview-do-not-export",
|
||||||
|
"private-manifest-do-not-export",
|
||||||
|
"private-receipt-do-not-export",
|
||||||
|
"private-archive-reference-do-not-export",
|
||||||
|
"private-item-key-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(secret, exported)
|
||||||
|
|
||||||
|
def test_authoritative_source_reference_correlates_the_record(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="applicant-1",
|
||||||
|
external_references={"cases.case": "case-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
item = next(
|
||||||
|
record for record in records if record.resource_type == "record_item"
|
||||||
|
)
|
||||||
|
self.assertTrue(item.data["source_reference_matches"])
|
||||||
|
self.assertEqual("record-1", item.data["record_id"])
|
||||||
|
|
||||||
|
def test_operator_search_returns_attribution_without_record_contents(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="clerk-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(records)
|
||||||
|
self.assertEqual(
|
||||||
|
{"records_operator_attribution"},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertNotIn("Application by Ada Example", exported)
|
||||||
|
self.assertNotIn("private-", exported)
|
||||||
|
self.assertIn("changed_file_plan", exported)
|
||||||
|
|
||||||
|
def test_account_search_exports_only_the_subjects_restricted_grant(self) -> None:
|
||||||
|
self.session.add(
|
||||||
|
RecordAccessGrantRevision(
|
||||||
|
id="grant-row-subject-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
grant_id="grant-subject-1",
|
||||||
|
record_id="record-1",
|
||||||
|
revision=1,
|
||||||
|
status="active",
|
||||||
|
subject_type="account",
|
||||||
|
subject_id="account-grantee-1",
|
||||||
|
actions=["read"],
|
||||||
|
allowed_purposes=["case-work"],
|
||||||
|
reason="Sensitive internal assignment reason",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
changed_by="records-admin-1",
|
||||||
|
institutional_context={"case_id": "case-1"},
|
||||||
|
idempotency_key="grant-subject-create-1",
|
||||||
|
request_sha256="a" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-grantee-1"),
|
||||||
|
)
|
||||||
|
grants = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.resource_type == "record_access_grant_revision"
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(grants))
|
||||||
|
self.assertEqual(["case-work"], grants[0].data["allowed_purposes"])
|
||||||
|
self.assertNotIn("reason", grants[0].data)
|
||||||
|
self.assertNotIn("account-grantee-1", json.dumps(grants[0].data))
|
||||||
|
|
||||||
|
def test_direct_selectors_fail_closed_on_conflict_or_wrong_tenant(self) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"records.record": "record-2",
|
||||||
|
"records.item": "item-row-1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
alias_conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"records.record": "record-1",
|
||||||
|
"records.record_id": "record-2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_tenant = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
|
||||||
|
)
|
||||||
|
mismatched_actor = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="someone-else",
|
||||||
|
external_references={"records.record": "record-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), alias_conflict)
|
||||||
|
self.assertEqual((), wrong_tenant)
|
||||||
|
self.assertEqual((), mismatched_actor)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_routes_current_fact_to_review(self) -> None:
|
||||||
|
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, sum(action.kind == "manual_review" for action in actions))
|
||||||
|
self.assertGreater(sum(action.kind == "retain" for action in actions), 7)
|
||||||
|
self.assertTrue(all(not action.executable for action in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
category="case",
|
||||||
|
title="Case",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:retain:case:case-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="retain",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Retain case",
|
||||||
|
rationale="Evidence",
|
||||||
|
executable=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-RECORDS-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[RECORDS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
|
||||||
|
)
|
||||||
|
self.assertGreater(row.search_result["record_count"], 8)
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-RECORDS-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[RECORDS_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||||
|
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
RECORDS_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "records.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+34
-12
@@ -2,22 +2,44 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
|
||||||
|
from govoplan_records.backend.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
get_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManifestSeedTests(unittest.TestCase):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_records_vertical(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "records")
|
self.assertEqual("records", manifest.id)
|
||||||
self.assertEqual(manifest.name, "Records")
|
self.assertEqual("Records", manifest.name)
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(("access",), manifest.dependencies)
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
self.assertEqual(
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"records_manager", "records_viewer"})
|
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||||
self.assertTrue(manifest.documentation)
|
{permission.scope for permission in manifest.permissions},
|
||||||
self.assertIsNone(manifest.route_factory)
|
)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertEqual(
|
||||||
self.assertIsNone(manifest.frontend)
|
{"records_manager", "records_viewer", "records_administrator"},
|
||||||
|
{role.slug for role in manifest.role_templates},
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertIn(CAPABILITY_RECORDS_FILING, manifest.capability_factories)
|
||||||
|
self.assertEqual(
|
||||||
|
"vertical_slice",
|
||||||
|
manifest.architecture.maturity if manifest.architecture else None,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"enforced", manifest.information_governance.temporal_browsing.adoption
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"enforced", manifest.information_governance.purpose_aware_access.adoption
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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_access_grant_revisions",
|
||||||
|
"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(
|
||||||
|
"8a6c4e2f1b3d",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceJourneyUiTests(unittest.TestCase):
|
||||||
|
def test_actor_can_resume_and_reconstruct_the_record_journey(self) -> None:
|
||||||
|
page = (ROOT / "webui/src/features/records/RecordsPage.tsx").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
lifecycle = (
|
||||||
|
ROOT / "webui/src/features/records/RecordLifecyclePanel.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
api = (ROOT / "webui/src/api/records.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for marker in (
|
||||||
|
'searchParams.get("recordId")',
|
||||||
|
'next.set("recordId", selectedRecordId)',
|
||||||
|
"filingPresetFrom(searchParams)",
|
||||||
|
'searchParams.get("sourceRevision")',
|
||||||
|
'<WorkspaceActionBar',
|
||||||
|
"refreshable",
|
||||||
|
'reloadAction={{ onReload: reload',
|
||||||
|
'data-help-context-id="records.governance-context"',
|
||||||
|
'data-help-context-id="records.record-items"',
|
||||||
|
'data-help-context-id="records.chronology"',
|
||||||
|
"item.source.source_revision",
|
||||||
|
"item.authority_mode",
|
||||||
|
"item.relationship",
|
||||||
|
"item.purpose",
|
||||||
|
"item.source_metadata.evidence_role",
|
||||||
|
"detail.access_explanation.current_authorization",
|
||||||
|
):
|
||||||
|
self.assertIn(marker, page)
|
||||||
|
|
||||||
|
for action in (
|
||||||
|
'openAction("close")',
|
||||||
|
'openAction("reopen")',
|
||||||
|
'openAction("appraise")',
|
||||||
|
'openAction("hold")',
|
||||||
|
'openAction("release-hold", holdId)',
|
||||||
|
'openAction("disposition")',
|
||||||
|
'openAction("finalize")',
|
||||||
|
'openAction("prepare-transfer")',
|
||||||
|
'openAction("dispatch-transfer")',
|
||||||
|
"openRecovery",
|
||||||
|
"Release all active holds first.",
|
||||||
|
"Simulation only",
|
||||||
|
"custody",
|
||||||
|
"item.policy_refs",
|
||||||
|
):
|
||||||
|
self.assertIn(action, lifecycle)
|
||||||
|
|
||||||
|
for endpoint in (
|
||||||
|
'"close"',
|
||||||
|
'"reopen"',
|
||||||
|
'"appraise"',
|
||||||
|
'"holds"',
|
||||||
|
'"dispositions"',
|
||||||
|
'"transfer-packages"',
|
||||||
|
"/recovery`",
|
||||||
|
):
|
||||||
|
self.assertIn(endpoint, api)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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 (
|
||||||
|
RecordAccessGrantRevision,
|
||||||
|
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)
|
||||||
|
RecordAccessGrantRevision.__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 test_restricted_search_requires_an_explicit_search_purpose_grant(self) -> None:
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
RecordIdentity(
|
||||||
|
id="identity-restricted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-restricted",
|
||||||
|
record_number="EA-RESTRICTED",
|
||||||
|
),
|
||||||
|
RecordRevision(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
record_id="record-restricted",
|
||||||
|
identity_id="identity-restricted",
|
||||||
|
revision=1,
|
||||||
|
class_id="class-1",
|
||||||
|
file_plan_node_id="plan-1",
|
||||||
|
title="Restricted decision",
|
||||||
|
state="open",
|
||||||
|
source_authority_mode="native_authoritative",
|
||||||
|
access_mode="restricted",
|
||||||
|
purpose="case-work",
|
||||||
|
institutional_context={},
|
||||||
|
external_reference={},
|
||||||
|
search_text="restricted decision",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
snapshot={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="records",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id="record-restricted",
|
||||||
|
)
|
||||||
|
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||||
|
self.assertFalse(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session, _principal({READ_SCOPE}), requests=(request,)
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session.add(
|
||||||
|
RecordAccessGrantRevision(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
grant_id="search-grant-1",
|
||||||
|
record_id="record-restricted",
|
||||||
|
revision=1,
|
||||||
|
status="active",
|
||||||
|
subject_type="account",
|
||||||
|
subject_id="account-1",
|
||||||
|
actions=["read"],
|
||||||
|
allowed_purposes=["records.search"],
|
||||||
|
reason="Search is required for assigned work.",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
changed_by="account-1",
|
||||||
|
institutional_context={},
|
||||||
|
idempotency_key="search-grant-create-1",
|
||||||
|
request_sha256="a" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session, _principal({READ_SCOPE}), requests=(request,)
|
||||||
|
)[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.23",
|
||||||
|
"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,431 @@
|
|||||||
|
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;
|
||||||
|
source_valid_from?: string | null;
|
||||||
|
source_valid_to?: string | null;
|
||||||
|
source_recorded_at?: string | null;
|
||||||
|
launch_url?: string | null;
|
||||||
|
filed_at: string;
|
||||||
|
filed_by?: string | null;
|
||||||
|
institutional_context: Record<string, unknown>;
|
||||||
|
source_metadata: Record<string, unknown>;
|
||||||
|
filing_metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
authority_mode: string;
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
manifest_sha256: string;
|
||||||
|
receipt: Record<string, unknown>;
|
||||||
|
receipt_sha256?: string | null;
|
||||||
|
external_reference?: string | null;
|
||||||
|
recovery_operation_id?: string | null;
|
||||||
|
simulated: boolean;
|
||||||
|
institutional_context: Record<string, unknown>;
|
||||||
|
recorded_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordAccessGrant = {
|
||||||
|
grant_id: string;
|
||||||
|
record_id: string;
|
||||||
|
revision: number;
|
||||||
|
status: "active" | "revoked" | string;
|
||||||
|
subject_type: "account" | "membership" | "group" | "role" | "function_assignment" | "delegation";
|
||||||
|
subject_id: string;
|
||||||
|
actions: Array<"read" | "write" | "manage">;
|
||||||
|
allowed_purposes: string[];
|
||||||
|
reason: string;
|
||||||
|
valid_from: string;
|
||||||
|
valid_to?: string | null;
|
||||||
|
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;
|
||||||
|
grant_id?: string | null;
|
||||||
|
grant_subject_type?: string | null;
|
||||||
|
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;
|
||||||
|
purpose?: 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,
|
||||||
|
purpose: options.purpose,
|
||||||
|
offset: options.offset,
|
||||||
|
limit: options.limit ?? 50
|
||||||
|
}), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecord(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
purpose?: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<RecordDetail> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}`, { purpose }), { 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,
|
||||||
|
purpose: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { purpose }), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRecordAccessGrants(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
purpose: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ grants: RecordAccessGrant[] }> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/access-grants`, { purpose }), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordAccessGrant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<RecordAccessGrant> {
|
||||||
|
return recordMutation(settings, recordId, "access-grants", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeRecordAccessGrant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
recordId: string,
|
||||||
|
grantId: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<RecordAccessGrant> {
|
||||||
|
return recordMutation(settings, recordId, `access-grants/${encodeURIComponent(grantId)}/revoke`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,237 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogForm,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingIndicator,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createRecordAccessGrant,
|
||||||
|
listRecordAccessGrants,
|
||||||
|
revokeRecordAccessGrant,
|
||||||
|
type RecordAccessGrant,
|
||||||
|
type RecordEntry
|
||||||
|
} from "../../api/records";
|
||||||
|
|
||||||
|
type SubjectType = RecordAccessGrant["subject_type"];
|
||||||
|
|
||||||
|
export function RecordAccessGrantsDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
record,
|
||||||
|
managementPurpose,
|
||||||
|
onClose,
|
||||||
|
onChanged
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: ApiSettings;
|
||||||
|
record: RecordEntry | null;
|
||||||
|
managementPurpose: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [grants, setGrants] = useState<RecordAccessGrant[]>([]);
|
||||||
|
const [subjectType, setSubjectType] = useState<SubjectType>("account");
|
||||||
|
const [subjectId, setSubjectId] = useState("");
|
||||||
|
const [read, setRead] = useState(true);
|
||||||
|
const [write, setWrite] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [allowedPurposes, setAllowedPurposes] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [validTo, setValidTo] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
if (!open || !record || !managementPurpose.trim()) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
listRecordAccessGrants(
|
||||||
|
settings,
|
||||||
|
record.record_id,
|
||||||
|
managementPurpose.trim(),
|
||||||
|
controller.signal
|
||||||
|
).then((result) => setGrants(result.grants)).catch((cause) => {
|
||||||
|
if ((cause as Error).name !== "AbortError") {
|
||||||
|
setError(errorMessage(cause, "The restricted access grants could not be loaded."));
|
||||||
|
}
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(reload, [managementPurpose, open, record?.record_id, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSubjectType("account");
|
||||||
|
setSubjectId("");
|
||||||
|
setRead(true);
|
||||||
|
setWrite(false);
|
||||||
|
setManage(false);
|
||||||
|
setAllowedPurposes("");
|
||||||
|
setReason("");
|
||||||
|
setValidTo("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!record) return;
|
||||||
|
const actions = [read ? "read" : "", write ? "write" : "", manage ? "manage" : ""].filter(Boolean);
|
||||||
|
const purposes = splitValues(allowedPurposes);
|
||||||
|
if (!subjectId.trim() || actions.length === 0 || purposes.length === 0 || !reason.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createRecordAccessGrant(settings, record.record_id, {
|
||||||
|
subject_type: subjectType,
|
||||||
|
subject_id: subjectId.trim(),
|
||||||
|
actions,
|
||||||
|
allowed_purposes: purposes,
|
||||||
|
reason: reason.trim(),
|
||||||
|
purpose: managementPurpose.trim(),
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
valid_to: validTo ? new Date(validTo).toISOString() : null,
|
||||||
|
institutional_context: {},
|
||||||
|
idempotency_key: randomId()
|
||||||
|
});
|
||||||
|
setSubjectId("");
|
||||||
|
setAllowedPurposes("");
|
||||||
|
setReason("");
|
||||||
|
setValidTo("");
|
||||||
|
onChanged();
|
||||||
|
reload();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause, "The restricted access grant could not be created."));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(grant: RecordAccessGrant) {
|
||||||
|
if (!record || !window.confirm("Revoke this restricted-record access grant?")) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await revokeRecordAccessGrant(settings, record.record_id, grant.grant_id, {
|
||||||
|
expected_revision: grant.revision,
|
||||||
|
reason: "Access grant revoked by a Records administrator.",
|
||||||
|
purpose: managementPurpose.trim(),
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
idempotency_key: randomId()
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
reload();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause, "The restricted access grant could not be revoked."));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionsSelected = read || write || manage;
|
||||||
|
const canSubmit = Boolean(
|
||||||
|
record && managementPurpose.trim() && subjectId.trim() && actionsSelected &&
|
||||||
|
splitValues(allowedPurposes).length && reason.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Restricted record access"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={saving}
|
||||||
|
portal
|
||||||
|
className="records-dialog records-access-dialog"
|
||||||
|
helpContextId="records.restricted-access"
|
||||||
|
footer={<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>Close</Button>}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Grants are effective-dated and match an exact subject, action, and declared purpose.
|
||||||
|
Current grants also govern historical record views.
|
||||||
|
</p>
|
||||||
|
<p><strong>Management purpose:</strong> {managementPurpose || "Not supplied"}</p>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{loading ? <LoadingIndicator label="Loading access grants" /> : grants.length === 0 ? (
|
||||||
|
<StatePanel size="inline" description="No current access grants are available." />
|
||||||
|
) : (
|
||||||
|
<div className="records-grant-list">
|
||||||
|
{grants.map((grant) => (
|
||||||
|
<div className="records-grant-row" key={grant.grant_id}>
|
||||||
|
<div>
|
||||||
|
<strong>{humanize(grant.subject_type)} · {grant.subject_id}</strong>
|
||||||
|
<span>{grant.actions.map(humanize).join(", ")} · {grant.allowed_purposes.join(", ")}</span>
|
||||||
|
<small>{grant.reason}</small>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={grant.status === "active" ? "active" : "neutral"} label={humanize(grant.status)} />
|
||||||
|
{grant.status === "active" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => revoke(grant)}
|
||||||
|
disabled={saving}
|
||||||
|
helpContextId="records.restricted-access"
|
||||||
|
helpModuleId="records"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogForm id="records-access-grant-form" className="records-dialog-form" onSubmit={submit}>
|
||||||
|
<h3>Add access grant</h3>
|
||||||
|
<FormField label="Subject type">
|
||||||
|
<select value={subjectType} onChange={(event) => setSubjectType(event.target.value as SubjectType)}>
|
||||||
|
<option value="account">Account</option>
|
||||||
|
<option value="membership">Membership</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
<option value="role">Role</option>
|
||||||
|
<option value="function_assignment">Function assignment</option>
|
||||||
|
<option value="delegation">Delegation</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Subject identifier"><input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} required /></FormField>
|
||||||
|
<FormField label="Actions">
|
||||||
|
<div className="records-grant-actions">
|
||||||
|
<label><input type="checkbox" checked={read} onChange={(event) => setRead(event.target.checked)} /> Read</label>
|
||||||
|
<label><input type="checkbox" checked={write} onChange={(event) => setWrite(event.target.checked)} /> Write</label>
|
||||||
|
<label><input type="checkbox" checked={manage} onChange={(event) => setManage(event.target.checked)} /> Manage</label>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Allowed purposes"><textarea value={allowedPurposes} onChange={(event) => setAllowedPurposes(event.target.value)} rows={3} placeholder="One purpose per line" required /></FormField>
|
||||||
|
<FormField label="Valid until"><input type="datetime-local" value={validTo} onChange={(event) => setValidTo(event.target.value)} /></FormField>
|
||||||
|
<FormField label="Reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} required /></FormField>
|
||||||
|
<Button type="submit" variant="primary" disabledReason={saving ? "The grant is being saved." : !canSubmit ? "Complete the subject, action, purpose, and reason." : undefined}>
|
||||||
|
{saving ? "Saving" : "Add grant"}
|
||||||
|
</Button>
|
||||||
|
</DialogForm>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitValues(value: string): string[] {
|
||||||
|
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId(): string {
|
||||||
|
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(reason: unknown, fallback: string): string {
|
||||||
|
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { DialogForm,
|
||||||
|
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>}
|
||||||
|
<DialogForm 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)" helpContextId="records.field.retention-trigger" helpModuleId="records"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger" helpContextId="records.field.retention-trigger" helpModuleId="records"><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>
|
||||||
|
</DialogForm>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId() {
|
||||||
|
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import {
|
||||||
|
ArchiveRestore,
|
||||||
|
Ban,
|
||||||
|
Box,
|
||||||
|
CheckCheck,
|
||||||
|
CirclePause,
|
||||||
|
FolderPlus,
|
||||||
|
LockKeyhole,
|
||||||
|
Play,
|
||||||
|
RotateCcw
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
import { DialogForm,
|
||||||
|
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, purpose.trim()));
|
||||||
|
} 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" helpContextId="records.lifecycle.disposition" helpModuleId="records" 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" helpContextId="records.lifecycle.finalize" helpModuleId="records" 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" helpContextId="records.lifecycle.disposition" helpModuleId="records" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>}
|
||||||
|
{availableActions.canPrepare && <Button type="button" variant="primary" helpContextId="records.lifecycle.prepare-transfer" helpModuleId="records" 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} /> : (
|
||||||
|
<DialogForm 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>
|
||||||
|
</DialogForm>
|
||||||
|
)}
|
||||||
|
</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}{hold.policy_refs.length > 0 ? ` · policy ${hold.policy_refs.join(", ")}` : ""}</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.policy_refs.length > 0 && <small>Policy: {item.policy_refs.join(", ")}</small>}{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransferEvidence({ item }: { item: RecordTransferPackage }) {
|
||||||
|
const receiptMetadata = typeof item.receipt.metadata === "object" && item.receipt.metadata != null
|
||||||
|
? item.receipt.metadata as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const custodyTransferred = receiptMetadata.custody_transferred === true;
|
||||||
|
const custodySummary = item.simulated
|
||||||
|
? "Simulation only; no archival custody."
|
||||||
|
: custodyTransferred
|
||||||
|
? "Archive custody was transferred."
|
||||||
|
: "Archive custody was not transferred.";
|
||||||
|
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><small>{custodySummary} Authority: {humanize(item.authority_mode)}.</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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,424 @@
|
|||||||
|
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",
|
||||||
|
"Governance context": "Governance context",
|
||||||
|
"Service": "Service",
|
||||||
|
"Case": "Case",
|
||||||
|
"Applicant": "Applicant",
|
||||||
|
"Representative": "Representative",
|
||||||
|
"Representation": "Representation",
|
||||||
|
"Responsible function": "Responsible function",
|
||||||
|
"Mandate": "Mandate",
|
||||||
|
"Legal basis": "Legal basis",
|
||||||
|
"Retention policy": "Retention policy",
|
||||||
|
"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",
|
||||||
|
"Relationship": "Relationship",
|
||||||
|
"Source recorded": "Source recorded",
|
||||||
|
"Evidence role": "Evidence role",
|
||||||
|
"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.",
|
||||||
|
"Archive custody was transferred.": "Archive custody was transferred.",
|
||||||
|
"Archive custody was not transferred.": "Archive custody was not transferred.",
|
||||||
|
"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.",
|
||||||
|
"Access purpose": "Access purpose",
|
||||||
|
"Manage access": "Manage access",
|
||||||
|
"Enter the management purpose in the access-purpose filter first.": "Enter the management purpose in the access-purpose filter first.",
|
||||||
|
"Access mode": "Access mode",
|
||||||
|
"Evaluated purpose": "Evaluated purpose",
|
||||||
|
"Tenant access": "Tenant access",
|
||||||
|
"Matched grant": "Matched grant",
|
||||||
|
"Tenant-wide permission": "Tenant-wide permission",
|
||||||
|
"Purpose-bound grants": "Purpose-bound grants",
|
||||||
|
"Initial allowed purposes": "Initial allowed purposes",
|
||||||
|
"One purpose per line": "One purpose per line",
|
||||||
|
"Include the action purpose in the initial allowed purposes.": "Include the action purpose in the initial allowed purposes.",
|
||||||
|
"Restricted record access": "Restricted record access",
|
||||||
|
"Management purpose:": "Management purpose:",
|
||||||
|
"Not supplied": "Not supplied",
|
||||||
|
"Loading access grants": "Loading access grants",
|
||||||
|
"No current access grants are available.": "No current access grants are available.",
|
||||||
|
"Add access grant": "Add access grant",
|
||||||
|
"Subject type": "Subject type",
|
||||||
|
"Subject identifier": "Subject identifier",
|
||||||
|
"Actions": "Actions",
|
||||||
|
"Allowed purposes": "Allowed purposes",
|
||||||
|
"Valid until": "Valid until",
|
||||||
|
"Read": "Read",
|
||||||
|
"Write": "Write",
|
||||||
|
"Manage": "Manage",
|
||||||
|
"Account": "Account",
|
||||||
|
"Membership": "Membership",
|
||||||
|
"Group": "Group",
|
||||||
|
"Role": "Role",
|
||||||
|
"Function assignment": "Function assignment",
|
||||||
|
"Delegation": "Delegation",
|
||||||
|
"Revoke": "Revoke",
|
||||||
|
"Add grant": "Add grant",
|
||||||
|
"The restricted access grants could not be loaded.": "The restricted access grants could not be loaded.",
|
||||||
|
"The restricted access grant could not be created.": "The restricted access grant could not be created.",
|
||||||
|
"The restricted access grant could not be revoked.": "The restricted access grant could not be revoked.",
|
||||||
|
"The grant is being saved.": "The grant is being saved.",
|
||||||
|
"Complete the subject, action, purpose, and reason.": "Complete the subject, action, purpose, and reason.",
|
||||||
|
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.",
|
||||||
|
"Active": "Active",
|
||||||
|
"Revoked": "Revoked"
|
||||||
|
} 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",
|
||||||
|
"Governance context": "Governance-Kontext",
|
||||||
|
"Service": "Leistung",
|
||||||
|
"Case": "Vorgang",
|
||||||
|
"Applicant": "Antragstellende Person",
|
||||||
|
"Representative": "Vertretende Person",
|
||||||
|
"Representation": "Vertretung",
|
||||||
|
"Responsible function": "Zuständige Funktion",
|
||||||
|
"Mandate": "Mandat",
|
||||||
|
"Legal basis": "Rechtsgrundlage",
|
||||||
|
"Retention policy": "Aufbewahrungsrichtlinie",
|
||||||
|
"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",
|
||||||
|
"Relationship": "Beziehung",
|
||||||
|
"Source recorded": "Quelle erfasst am",
|
||||||
|
"Evidence role": "Nachweisrolle",
|
||||||
|
"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.",
|
||||||
|
"Archive custody was transferred.": "Die Archivverwahrung wurde übertragen.",
|
||||||
|
"Archive custody was not transferred.": "Die Archivverwahrung wurde nicht übertragen.",
|
||||||
|
"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.",
|
||||||
|
"Access purpose": "Zugriffszweck",
|
||||||
|
"Manage access": "Zugriff verwalten",
|
||||||
|
"Enter the management purpose in the access-purpose filter first.": "Geben Sie zuerst den Verwaltungszweck im Zugriffszweckfilter an.",
|
||||||
|
"Access mode": "Zugriffsmodus",
|
||||||
|
"Evaluated purpose": "Geprüfter Zweck",
|
||||||
|
"Tenant access": "Mandantenzugriff",
|
||||||
|
"Matched grant": "Passende Freigabe",
|
||||||
|
"Tenant-wide permission": "Mandantenweite Berechtigung",
|
||||||
|
"Purpose-bound grants": "Zweckgebundene Freigaben",
|
||||||
|
"Initial allowed purposes": "Anfänglich erlaubte Zwecke",
|
||||||
|
"One purpose per line": "Ein Zweck pro Zeile",
|
||||||
|
"Include the action purpose in the initial allowed purposes.": "Nehmen Sie den Aktionszweck in die anfänglich erlaubten Zwecke auf.",
|
||||||
|
"Restricted record access": "Zugriff auf besonders geschützte Akten",
|
||||||
|
"Management purpose:": "Verwaltungszweck:",
|
||||||
|
"Not supplied": "Nicht angegeben",
|
||||||
|
"Loading access grants": "Zugriffsfreigaben werden geladen",
|
||||||
|
"No current access grants are available.": "Es sind keine aktuellen Zugriffsfreigaben vorhanden.",
|
||||||
|
"Add access grant": "Zugriffsfreigabe hinzufügen",
|
||||||
|
"Subject type": "Subjekttyp",
|
||||||
|
"Subject identifier": "Subjektkennung",
|
||||||
|
"Actions": "Aktionen",
|
||||||
|
"Allowed purposes": "Erlaubte Zwecke",
|
||||||
|
"Valid until": "Gültig bis",
|
||||||
|
"Read": "Lesen",
|
||||||
|
"Write": "Schreiben",
|
||||||
|
"Manage": "Verwalten",
|
||||||
|
"Account": "Konto",
|
||||||
|
"Membership": "Mitgliedschaft",
|
||||||
|
"Group": "Gruppe",
|
||||||
|
"Role": "Rolle",
|
||||||
|
"Function assignment": "Funktionszuordnung",
|
||||||
|
"Delegation": "Delegation",
|
||||||
|
"Revoke": "Entziehen",
|
||||||
|
"Add grant": "Freigabe hinzufügen",
|
||||||
|
"The restricted access grants could not be loaded.": "Die Freigaben für besonders geschützte Akten konnten nicht geladen werden.",
|
||||||
|
"The restricted access grant could not be created.": "Die Freigabe für die besonders geschützte Akte konnte nicht angelegt werden.",
|
||||||
|
"The restricted access grant could not be revoked.": "Die Freigabe für die besonders geschützte Akte konnte nicht entzogen werden.",
|
||||||
|
"The grant is being saved.": "Die Freigabe wird gespeichert.",
|
||||||
|
"Complete the subject, action, purpose, and reason.": "Vervollständigen Sie Subjekt, Aktion, Zweck und Begründung.",
|
||||||
|
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Freigaben sind zeitlich wirksam und gelten für ein eindeutiges Subjekt, eine Aktion und einen erklärten Zweck. Aktuelle Freigaben bestimmen auch den Zugriff auf historische Aktenansichten.",
|
||||||
|
"Active": "Aktiv",
|
||||||
|
"Revoked": "Entzogen"
|
||||||
|
};
|
||||||
|
|
||||||
|
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,667 @@
|
|||||||
|
.records-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-search {
|
||||||
|
flex: 1 1 420px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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-context-facts,
|
||||||
|
.records-item-evidence {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-context-facts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-context-facts > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-context-facts dt,
|
||||||
|
.records-item-evidence dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-context-facts dd,
|
||||||
|
.records-item-evidence dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--radius-round);
|
||||||
|
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-item-evidence {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-item-evidence > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--radius-round);
|
||||||
|
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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-block: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-row > div,
|
||||||
|
.records-grant-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-actions label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.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: 680px) {
|
||||||
|
.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-context-facts,
|
||||||
|
.records-item-evidence,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-grant-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user