Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00bec0ed94 | ||
|
|
cfcd723496 | ||
|
|
06afa79795 | ||
|
|
727c70f756 | ||
|
|
b9132d076d | ||
|
|
5bc651a8e7 | ||
|
|
0d7d18c486 | ||
|
|
178d08c729 | ||
|
|
50e0172f65 | ||
|
|
abf35d75f3 | ||
|
|
cf7da905a7 | ||
|
|
a80e2fa870 | ||
|
|
b3cd080e5a | ||
|
|
f9bc774109 | ||
|
|
9a8dc9f22c | ||
|
|
34f7dd432b | ||
|
|
6b2b352009 | ||
|
|
e505536e6f | ||
|
|
e2033640a1 | ||
|
|
343a208894 | ||
|
|
5825c2c8e4 |
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GovOPlaN Forms Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns immutable reusable form definitions and their designer.
|
||||||
|
Runtime values, drafts, receipts, attachments, signatures, and handoffs belong
|
||||||
|
to `govoplan-forms-runtime` or the corresponding domain owner.
|
||||||
|
|
||||||
|
## Working Rules
|
||||||
|
|
||||||
|
- Resolve cross-module behavior through Core contracts and capabilities; do not
|
||||||
|
import another optional module's tables or WebUI pages.
|
||||||
|
- Keep definition revisions immutable and tenant-bound. Schema changes create a
|
||||||
|
new revision and use optimistic concurrency.
|
||||||
|
- Keep submitted values out of Forms events, logs, and definition storage.
|
||||||
|
- Every behavior or UI change must update the relevant user/admin documentation
|
||||||
|
and architecture declaration in the same change.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
|
```
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
# GovOPlaN Forms
|
# GovOPlaN Forms
|
||||||
|
|
||||||
`govoplan-forms` owns reusable form definitions, validation rules, and form
|
<!-- govoplan-repository-type:start -->
|
||||||
package fragments. Submission runtime behavior is a separate responsibility
|
**Repository type:** module (domain).
|
||||||
that should live in `govoplan-forms-runtime` when implemented.
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
This repository is currently a tag-only scaffold. It should gain package
|
`govoplan-forms` owns reusable, immutable form definitions, validation rules,
|
||||||
metadata and module manifests only after the first backend or WebUI slice is
|
and form package fragments. Submission runtime behavior remains in
|
||||||
designed.
|
`govoplan-forms-runtime`.
|
||||||
|
|
||||||
|
The module persists exact tenant-bound revisions, exposes bounded catalogue,
|
||||||
|
history, and write APIs, and provides `forms.definitions` for consumers. A
|
||||||
|
published revision can be used by Forms Runtime without importing Forms tables;
|
||||||
|
existing submissions keep their exact revision when a new schema is published.
|
||||||
|
|
||||||
|
The `/forms` designer uses shared application controls to create, revise,
|
||||||
|
publish, retire, search, and inspect definitions. It edits field order, types,
|
||||||
|
choices, validation constraints, draft behavior, attachment and signature
|
||||||
|
requirements, policy references, and permitted handoff kinds. Saving always
|
||||||
|
creates an exact immutable revision; it never mutates a published schema in
|
||||||
|
place.
|
||||||
|
|
||||||
|
The designer also supports multi-page/section layout, bounded conditional
|
||||||
|
visibility expressions, localized labels/help/options/page titles, fallback
|
||||||
|
locale, and accessibility metadata. Server validation rejects unknown fields,
|
||||||
|
incompatible operators, placement errors, and condition cycles. Verified
|
||||||
|
configuration-package fragments can be assessed and imported as a new local
|
||||||
|
draft with source provenance rather than silently becoming active.
|
||||||
|
|
||||||
|
Definition writes use optimistic concurrency. Replaying the same object and
|
||||||
|
revision is safe only when the payload is identical. Published definitions may
|
||||||
|
be retired but not silently returned to draft.
|
||||||
|
|
||||||
|
Focused verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
|
```
|
||||||
|
|
||||||
See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision.
|
See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision.
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/forms-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/forms-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
+23
-14
@@ -18,7 +18,7 @@ Forms owns:
|
|||||||
- schema contracts that let portal, workflow, cases, and reporting understand a
|
- schema contracts that let portal, workflow, cases, and reporting understand a
|
||||||
form without importing form internals
|
form without importing form internals
|
||||||
|
|
||||||
`govoplan-forms-runtime` owns, when implemented:
|
`govoplan-forms-runtime` owns:
|
||||||
|
|
||||||
- public/internal submission sessions, drafts, receipts, submitted values,
|
- public/internal submission sessions, drafts, receipts, submitted values,
|
||||||
validation evidence, attachment references, and submission status
|
validation evidence, attachment references, and submission status
|
||||||
@@ -62,19 +62,28 @@ Form definitions should carry:
|
|||||||
- localization keys and fallback text
|
- localization keys and fallback text
|
||||||
- data classification for privacy/retention decisions
|
- data classification for privacy/retention decisions
|
||||||
|
|
||||||
## Candidate Capabilities
|
## Capabilities
|
||||||
|
|
||||||
- `forms.catalog`
|
- `forms.definitions` resolves an exact, tenant-bound immutable definition.
|
||||||
- `forms.schema`
|
- The API provides bounded catalogue and history reads plus OCC-guarded writes.
|
||||||
- `forms.validation`
|
- The designer provides explicit revision, publication, field-order, type,
|
||||||
- `forms.packageFragments`
|
option, constraint, draft, attachment, signature, policy, and handoff editing.
|
||||||
- `forms.runtime` when runtime is installed
|
- Forms Runtime performs value validation against the resolved definition; the
|
||||||
|
definition owner does not persist submissions.
|
||||||
|
|
||||||
## First Implementation Slice
|
## Recovery And Operations
|
||||||
|
|
||||||
1. Define manifest metadata, permissions, and capability names.
|
Definition revisions are append-only. Recovery restores the database and then
|
||||||
2. Add form definition/version DTOs.
|
verifies that every runtime submission's `form_id` and `form_revision` resolves
|
||||||
3. Add validation metadata for one reusable form schema.
|
to the same payload. Destructive module retirement requires a verified database
|
||||||
4. Add package fragment import/export format.
|
snapshot and is blocked while definition rows remain. The transactional event
|
||||||
5. Add tests that portal/workflow/reporting can detect form capabilities
|
boundary emits only schema identity, revision, publication state, and field
|
||||||
without importing form internals.
|
count; field content remains in the owning database.
|
||||||
|
|
||||||
|
The schema vocabulary covers scalar, choice, structured, attachment, signature,
|
||||||
|
policy-reference, and handoff constraints. Conditional page/section layout,
|
||||||
|
cycle-safe predicates, localization authoring, accessibility assessment, and
|
||||||
|
verified package-fragment assessment/import are implemented. Remaining depth is
|
||||||
|
concrete attachment/signature providers, richer authoring ergonomics, public
|
||||||
|
identity profiles, and target-produced accessibility evidence; those do not
|
||||||
|
change the runtime boundary.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Forms Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to the
|
||||||
|
Forms-owned definition catalogue, package import, and revision editor. Forms
|
||||||
|
owns immutable reusable schemas; Forms Runtime continues to own values,
|
||||||
|
submissions, receipts, and handoff execution.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/forms` catalogue | Searchable definition library | Filter, export, import, create, or revise | Shared loading/error/empty/permission/help states and stable row actions |
|
||||||
|
| Package import dialog | Consequential package review | Create local draft from external provenance | Assessment, required reason, guarded draft, never implicit publish |
|
||||||
|
| Definition dialog | Versioned definition editor | Create immutable revision | Guarded editor, contextual schema help, explained validation and permission states |
|
||||||
|
| Publication state | Governed lifecycle selector | Publish or retire exact revision | Admin-only availability and explicit lifecycle confirmation |
|
||||||
|
| Field/page/localization editors | Structured schema composition | Change future runtime schema | At least one field, stable ordering, preview, localization and accessibility metadata |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Every save creates an immutable revision with a reason. Runtime instances
|
||||||
|
retain the exact revision with which they were created.
|
||||||
|
- Publishing permits future authorized use. Retirement prevents future use but
|
||||||
|
does not erase definitions, submissions, receipts, or evidence.
|
||||||
|
- Imported packages become local drafts and retain source provenance.
|
||||||
|
- Missing write or administration authority remains visible with an Access
|
||||||
|
destination; retired definitions show a lifecycle explanation.
|
||||||
|
- Optional Runtime, Portal, Workflow, Case, and Policy behavior remains behind
|
||||||
|
declared interfaces and capabilities.
|
||||||
|
|
||||||
|
The module uses shared controls, dialogs, blockers, field help, statuses,
|
||||||
|
loading, empty/error states, disabled reasons, confirmations, and unsaved-draft
|
||||||
|
guards. The catalogue now composes `WorkspaceFrame`, `ActionToolbar`,
|
||||||
|
`FilterBar`, and `StatePanel`; equal-column definition/editor groups use
|
||||||
|
`FormGrid`, while field, page, localization, and preview headings use the
|
||||||
|
shared section-header toolbar surface. Core therefore owns viewport, search,
|
||||||
|
state, heading, and narrow-layout geometry. These contracts do not change
|
||||||
|
filter scope, permissions, revision semantics, or publication consequences.
|
||||||
|
English and German catalogues cover the owned route and editor vocabulary.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/forms-webui",
|
||||||
|
"version": "0.1.23",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/forms.css": "./webui/src/styles/forms.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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-forms"
|
||||||
|
version = "0.1.23"
|
||||||
|
description = "Immutable reusable form definitions for GovOPlaN."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.35", "govoplan-access>=0.1.18"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_forms = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
"forms" = "govoplan_forms.backend.manifest:get_manifest"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Forms module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.23"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms backend package."""
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.configuration_packages import (
|
||||||
|
ConfigurationApplyResult,
|
||||||
|
ConfigurationDiagnostic,
|
||||||
|
ConfigurationExportResult,
|
||||||
|
ConfigurationExportSelection,
|
||||||
|
ConfigurationPackageFragment,
|
||||||
|
ConfigurationPlanItem,
|
||||||
|
ConfigurationPreflightContext,
|
||||||
|
ConfigurationPreflightResult,
|
||||||
|
ConfigurationProvider,
|
||||||
|
ConfigurationProviderDescription,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
from govoplan_forms.backend.service import (
|
||||||
|
FormDefinitionStoreError,
|
||||||
|
assess_form_definition_fragment,
|
||||||
|
export_form_definition_fragment,
|
||||||
|
get_form_definition,
|
||||||
|
import_form_definition_fragment,
|
||||||
|
list_form_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FORMS_CONFIGURATION_CAPABILITY = "forms.configuration"
|
||||||
|
_WRITE_SCOPES = frozenset(
|
||||||
|
{
|
||||||
|
"forms:definition:write",
|
||||||
|
"admin:settings:write",
|
||||||
|
"system:settings:write",
|
||||||
|
"system:governance:write",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationPrincipal:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SqlFormsConfigurationProvider(ConfigurationProvider):
|
||||||
|
module_id = "forms"
|
||||||
|
|
||||||
|
def describe(self) -> ConfigurationProviderDescription:
|
||||||
|
return ConfigurationProviderDescription(
|
||||||
|
module_id=self.module_id,
|
||||||
|
fragment_types=("definition",),
|
||||||
|
schema_refs={
|
||||||
|
"definition": "govoplan/forms/configuration/definition.v1",
|
||||||
|
},
|
||||||
|
exported_scopes=("tenant",),
|
||||||
|
)
|
||||||
|
|
||||||
|
def preflight(
|
||||||
|
self,
|
||||||
|
fragment: ConfigurationPackageFragment,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> ConfigurationPreflightResult:
|
||||||
|
with get_database().session() as session:
|
||||||
|
return _preflight_definition(session, fragment, context)
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
fragment: ConfigurationPackageFragment,
|
||||||
|
supplied_data: Mapping[str, Any],
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> ConfigurationApplyResult:
|
||||||
|
del supplied_data
|
||||||
|
with get_database().session() as session:
|
||||||
|
result = _apply_definition(session, fragment, context)
|
||||||
|
if not any(item.severity == "blocker" for item in result.diagnostics):
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def export(
|
||||||
|
self,
|
||||||
|
selection: ConfigurationExportSelection,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> ConfigurationExportResult:
|
||||||
|
tenant_id = selection.tenant_id or context.tenant_id
|
||||||
|
if not tenant_id:
|
||||||
|
return ConfigurationExportResult(diagnostics=(_tenant_required(),))
|
||||||
|
principal = _ConfigurationPrincipal(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=context.operator_user_id,
|
||||||
|
)
|
||||||
|
selected_ids = {
|
||||||
|
item.removeprefix("form:")
|
||||||
|
for item in selection.object_refs
|
||||||
|
if item.startswith("form:")
|
||||||
|
}
|
||||||
|
with get_database().session() as session:
|
||||||
|
definitions, _total = list_form_definitions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
fragments = tuple(
|
||||||
|
ConfigurationPackageFragment(
|
||||||
|
module_id=self.module_id,
|
||||||
|
fragment_type="definition",
|
||||||
|
fragment_id=definition.reference.object_id,
|
||||||
|
payload={
|
||||||
|
"fragment": export_form_definition_fragment(
|
||||||
|
definition,
|
||||||
|
exported_at=datetime.now(UTC),
|
||||||
|
exported_by=context.operator_user_id,
|
||||||
|
),
|
||||||
|
"on_conflict": "new_revision",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for definition in definitions
|
||||||
|
if not selected_ids
|
||||||
|
or definition.reference.object_id in selected_ids
|
||||||
|
)
|
||||||
|
return ConfigurationExportResult(fragments=fragments)
|
||||||
|
|
||||||
|
def health(
|
||||||
|
self,
|
||||||
|
import_result: ConfigurationApplyResult,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> tuple[ConfigurationDiagnostic, ...]:
|
||||||
|
del context
|
||||||
|
return tuple(
|
||||||
|
item for item in import_result.diagnostics if item.severity == "blocker"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_definition(
|
||||||
|
session: Any,
|
||||||
|
fragment: ConfigurationPackageFragment,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> ConfigurationPreflightResult:
|
||||||
|
if fragment.fragment_type != "definition":
|
||||||
|
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
|
||||||
|
if not context.tenant_id:
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
diagnostics=(_tenant_required(fragment),),
|
||||||
|
plan=(_plan("blocked", fragment, "Select a target tenant."),),
|
||||||
|
)
|
||||||
|
if not (_WRITE_SCOPES & context.operator_scopes):
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
diagnostics=(_write_scope_required(fragment),),
|
||||||
|
plan=(_plan("blocked", fragment, "Forms write authority is missing."),),
|
||||||
|
)
|
||||||
|
principal = _ConfigurationPrincipal(
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
account_id=context.operator_user_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = _definition_payload(fragment)
|
||||||
|
assessment = assess_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=parsed["fragment"],
|
||||||
|
)
|
||||||
|
except (FormDefinitionStoreError, ValueError) as exc:
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
diagnostics=(_invalid(fragment, str(exc)),),
|
||||||
|
plan=(_plan("blocked", fragment, "Forms fragment is invalid."),),
|
||||||
|
)
|
||||||
|
target_form_id = parsed["target_form_id"] or str(
|
||||||
|
assessment["source"]["form_id"]
|
||||||
|
)
|
||||||
|
current = get_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
form_id=target_form_id,
|
||||||
|
)
|
||||||
|
if current is not None and _is_replay(current.metadata, parsed["fragment"]):
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
plan=(_plan("noop", fragment, "The same source definition is already imported."),)
|
||||||
|
)
|
||||||
|
if current is not None and parsed["on_conflict"] != "new_revision":
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
diagnostics=(_conflict(fragment, target_form_id),),
|
||||||
|
plan=(_plan("blocked", fragment, "Existing definition is preserved by package policy."),),
|
||||||
|
)
|
||||||
|
return ConfigurationPreflightResult(
|
||||||
|
plan=(_plan(
|
||||||
|
"update" if current is not None else "create",
|
||||||
|
fragment,
|
||||||
|
f"{'Create a new revision of' if current is not None else 'Create'} Form definition {target_form_id} as a draft.",
|
||||||
|
),)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_definition(
|
||||||
|
session: Any,
|
||||||
|
fragment: ConfigurationPackageFragment,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> ConfigurationApplyResult:
|
||||||
|
preflight = _preflight_definition(session, fragment, context)
|
||||||
|
if any(item.severity == "blocker" for item in preflight.diagnostics):
|
||||||
|
return ConfigurationApplyResult(diagnostics=preflight.diagnostics)
|
||||||
|
parsed = _definition_payload(fragment)
|
||||||
|
assert context.tenant_id is not None
|
||||||
|
principal = _ConfigurationPrincipal(
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
account_id=context.operator_user_id,
|
||||||
|
)
|
||||||
|
source = parsed["fragment"].get("provenance")
|
||||||
|
source_form_id = (
|
||||||
|
str(source.get("form_id") or "").strip()
|
||||||
|
if isinstance(source, Mapping)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
target_form_id = parsed["target_form_id"] or source_form_id
|
||||||
|
current = get_form_definition(session, principal, form_id=target_form_id)
|
||||||
|
if current is not None and _is_replay(current.metadata, parsed["fragment"]):
|
||||||
|
return ConfigurationApplyResult()
|
||||||
|
imported = import_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=parsed["fragment"],
|
||||||
|
target_form_id=target_form_id,
|
||||||
|
target_key=parsed["target_key"],
|
||||||
|
expected_revision=current.reference.version if current is not None else None,
|
||||||
|
change_reason=parsed["change_reason"],
|
||||||
|
recorded_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
reference = f"form:{imported.reference.object_id}:{imported.reference.version}"
|
||||||
|
key = fragment.fragment_id or imported.reference.object_id
|
||||||
|
if current is None:
|
||||||
|
return ConfigurationApplyResult(created_refs={key: reference})
|
||||||
|
return ConfigurationApplyResult(updated_refs={key: reference})
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_payload(fragment: ConfigurationPackageFragment) -> dict[str, Any]:
|
||||||
|
allowed = {
|
||||||
|
"fragment",
|
||||||
|
"target_form_id",
|
||||||
|
"target_key",
|
||||||
|
"change_reason",
|
||||||
|
"on_conflict",
|
||||||
|
}
|
||||||
|
unknown = sorted(set(fragment.payload) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Forms configuration payload contains unsupported fields: {', '.join(unknown)}."
|
||||||
|
)
|
||||||
|
source = fragment.payload.get("fragment")
|
||||||
|
if not isinstance(source, Mapping):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Forms configuration definition requires a fragment object."
|
||||||
|
)
|
||||||
|
on_conflict = str(fragment.payload.get("on_conflict") or "preserve").strip().casefold()
|
||||||
|
if on_conflict not in {"preserve", "new_revision"}:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Forms configuration on_conflict must be preserve or new_revision."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"fragment": dict(source),
|
||||||
|
"target_form_id": _optional_text(fragment.payload.get("target_form_id")),
|
||||||
|
"target_key": _optional_text(fragment.payload.get("target_key")),
|
||||||
|
"change_reason": _optional_text(fragment.payload.get("change_reason"))
|
||||||
|
or "Imported through a reviewed configuration package.",
|
||||||
|
"on_conflict": on_conflict,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_replay(metadata: Mapping[str, Any], source: Mapping[str, Any]) -> bool:
|
||||||
|
package_import = metadata.get("package_import")
|
||||||
|
if not isinstance(package_import, Mapping):
|
||||||
|
return False
|
||||||
|
return bool(source.get("definition_sha256")) and (
|
||||||
|
str(package_import.get("source_sha256") or "")
|
||||||
|
== str(source.get("definition_sha256") or "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(
|
||||||
|
action: str,
|
||||||
|
fragment: ConfigurationPackageFragment,
|
||||||
|
summary: str,
|
||||||
|
) -> ConfigurationPlanItem:
|
||||||
|
return ConfigurationPlanItem(
|
||||||
|
action=action, # type: ignore[arg-type]
|
||||||
|
module_id=fragment.module_id,
|
||||||
|
fragment_type=fragment.fragment_type,
|
||||||
|
fragment_id=fragment.fragment_id,
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_required(
|
||||||
|
fragment: ConfigurationPackageFragment | None = None,
|
||||||
|
) -> ConfigurationDiagnostic:
|
||||||
|
return ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="tenant_required",
|
||||||
|
message="Forms configuration import and export require a target tenant.",
|
||||||
|
module_id="forms",
|
||||||
|
object_ref=(fragment.fragment_id or fragment.fragment_type) if fragment else None,
|
||||||
|
resolution="Select a tenant before continuing.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_scope_required(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
||||||
|
return ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="forms_configuration_write_scope_required",
|
||||||
|
message="The operator may not import Form definitions for this tenant.",
|
||||||
|
module_id="forms",
|
||||||
|
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||||
|
resolution="Use an approved Forms designer or system configuration administrator.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(fragment: ConfigurationPackageFragment, form_id: str) -> ConfigurationDiagnostic:
|
||||||
|
return ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="forms_configuration_conflict",
|
||||||
|
message=f"Form definition {form_id!r} already exists and preserve is selected.",
|
||||||
|
module_id="forms",
|
||||||
|
object_ref=form_id,
|
||||||
|
resolution="Keep the local definition, choose a different target id, or review a package that explicitly creates a new revision.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(fragment: ConfigurationPackageFragment, message: str) -> ConfigurationDiagnostic:
|
||||||
|
return ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="forms_configuration_payload_invalid",
|
||||||
|
message=message,
|
||||||
|
module_id="forms",
|
||||||
|
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||||
|
resolution="Use a Forms definition fragment compatible with the installed provider schema.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
||||||
|
return ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="fragment_type_unsupported",
|
||||||
|
message=f"Forms configuration does not support fragment type {fragment.fragment_type!r}.",
|
||||||
|
module_id="forms",
|
||||||
|
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FORMS_CONFIGURATION_CAPABILITY",
|
||||||
|
"SqlFormsConfigurationProvider",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms database models."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, 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 FormDefinitionRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_definition_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"form_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_form_definition_revision",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_definition_current",
|
||||||
|
"tenant_id",
|
||||||
|
"form_id",
|
||||||
|
"superseded_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_definition_catalog",
|
||||||
|
"tenant_id",
|
||||||
|
"publication_state",
|
||||||
|
"form_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
form_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
form_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
revision: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
publication_state: Mapped[str] = mapped_column(
|
||||||
|
String(30), nullable=False, index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(500), 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
|
||||||
|
)
|
||||||
|
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
changed_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FormDefinitionRevision"]
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
|
||||||
|
|
||||||
|
FORMS_DSAR_CAPABILITY = dsar_capability_name("forms")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
|
||||||
|
|
||||||
|
class FormsDsarProvider:
|
||||||
|
provider_id = "forms"
|
||||||
|
module_id = "forms"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
account_id, form_id, revision_id = selectors
|
||||||
|
query = db.query(FormDefinitionRevision).filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.changed_by == account_id,
|
||||||
|
)
|
||||||
|
if form_id:
|
||||||
|
query = query.filter(FormDefinitionRevision.form_id == form_id)
|
||||||
|
if revision_id:
|
||||||
|
query = query.filter(FormDefinitionRevision.id == revision_id)
|
||||||
|
rows = (
|
||||||
|
query.order_by(
|
||||||
|
FormDefinitionRevision.recorded_at,
|
||||||
|
FormDefinitionRevision.id,
|
||||||
|
)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError("Forms DSAR result limit exceeded; narrow selectors.")
|
||||||
|
return tuple(_record(row) for row in rows)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Forms DSAR subject selectors conflict.")
|
||||||
|
actions = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"forms:retain:{record.resource_id}",
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=(
|
||||||
|
record.retention_reason
|
||||||
|
or "Form-definition attribution remains governance evidence."
|
||||||
|
),
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Forms DSAR subject selectors conflict.")
|
||||||
|
results = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable or action.kind != "retain":
|
||||||
|
raise ValueError("Forms DSAR publishes retain actions only.")
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary="Form-definition attribution remains governance evidence.",
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectors(subject: DsarSubjectRef) -> tuple[str, str | None, str | None] | None:
|
||||||
|
references = subject.external_references
|
||||||
|
account = _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("forms.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
)
|
||||||
|
form_id = _coalesce(references.get("forms.form"), references.get("forms.form_id"))
|
||||||
|
revision_id = _coalesce(
|
||||||
|
references.get("forms.revision"), references.get("forms.revision_id")
|
||||||
|
)
|
||||||
|
if account is _CONFLICT or form_id is _CONFLICT or revision_id is _CONFLICT:
|
||||||
|
return None
|
||||||
|
if not isinstance(account, str) or not account:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
account,
|
||||||
|
form_id if isinstance(form_id, str) else None,
|
||||||
|
revision_id if isinstance(revision_id, str) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(row: FormDefinitionRevision) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="forms",
|
||||||
|
module_id="forms",
|
||||||
|
resource_type="form_definition_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="form_definition_governance_attribution",
|
||||||
|
title="Form-definition actor attribution",
|
||||||
|
data={
|
||||||
|
"form_id": row.form_id,
|
||||||
|
"revision_id": row.id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"publication_state": row.publication_state,
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"activity": "recorded_form_definition_revision",
|
||||||
|
},
|
||||||
|
observed_at=_aware(row.recorded_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Form-definition author attribution is retained with immutable schema history."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Forms DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "forms" or record.module_id != "forms":
|
||||||
|
raise ValueError("Forms DSAR cannot plan a foreign provider record.")
|
||||||
|
if (
|
||||||
|
record.resource_type != "form_definition_actor_attribution"
|
||||||
|
or not record.resource_id
|
||||||
|
):
|
||||||
|
raise ValueError("Forms DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "forms" or action.module_id != "forms":
|
||||||
|
raise ValueError("Forms DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("forms:retain:"):
|
||||||
|
raise ValueError("Forms DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FORMS_DSAR_CAPABILITY", "FormsDsarProvider"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'forms.data-subject-requests': {'consequence_classes': {'exclude_form_semantics': 'Gibt keinen '
|
||||||
|
'Schema-, Feld- '
|
||||||
|
'oder '
|
||||||
|
'Submission-Value-Inhalt '
|
||||||
|
'zurück.',
|
||||||
|
'export_definition_attribution': 'Returns '
|
||||||
|
'minimierten '
|
||||||
|
'die '
|
||||||
|
'unveränderliche '
|
||||||
|
'Revisionsaktivität.'}},
|
||||||
|
'forms.definitions': {'privacy_notes': ['Definitionskataloge enthalten Schemata und '
|
||||||
|
'Richtlinienreferenzen, keine eingereichten '
|
||||||
|
'Formularwerte.',
|
||||||
|
'Die Paketbewertung gewährt keinen Zugriff auf '
|
||||||
|
'referenzierte Laufzeiteinreichungen oder externe '
|
||||||
|
'Anbieter.',
|
||||||
|
'Veröffentlichte Zugänglichkeits- und '
|
||||||
|
'Lokalisierungsinhalte sind überall dort sichtbar, wo die '
|
||||||
|
'genaue Definition autorisiert ist.']},
|
||||||
|
'forms.reference.fields-and-consequences': {'consequence_classes': {'import_package': 'Erstellt '
|
||||||
|
'einen '
|
||||||
|
'lokalen '
|
||||||
|
'Entwurf '
|
||||||
|
'und behält '
|
||||||
|
'die '
|
||||||
|
'Herkunft '
|
||||||
|
'des Pakets '
|
||||||
|
'ohne '
|
||||||
|
'automatische '
|
||||||
|
'Veröffentlichung.',
|
||||||
|
'publish': 'Macht die genaue '
|
||||||
|
'Revision für '
|
||||||
|
'zukünftige '
|
||||||
|
'autorisierte '
|
||||||
|
'Instanzen '
|
||||||
|
'verfügbar.',
|
||||||
|
'retire': 'Stoppt die '
|
||||||
|
'zukünftige '
|
||||||
|
'Nutzung, während '
|
||||||
|
'Definitionen und '
|
||||||
|
'genaue '
|
||||||
|
'Laufzeitreferenzen '
|
||||||
|
'beibehalten '
|
||||||
|
'werden.',
|
||||||
|
'save_revision': 'Erstellt '
|
||||||
|
'eine '
|
||||||
|
'unveränderliche '
|
||||||
|
'Definitionsrevision '
|
||||||
|
'mit einem '
|
||||||
|
'Änderungsgrund.'},
|
||||||
|
'limitations': ['Der Paketimport veröffentlicht '
|
||||||
|
'niemals automatisch eine '
|
||||||
|
'Formularrevision.',
|
||||||
|
'Laufzeiteingaben und eingereichte '
|
||||||
|
'Werte werden niemals als '
|
||||||
|
'Definitionskonfiguration exportiert.',
|
||||||
|
'Generische Paket-Rollback erfordert '
|
||||||
|
'die Voranwendung Datenbank Snapshot '
|
||||||
|
'beibehalten.'],
|
||||||
|
'operational_consequences': ['Preserve blockiert eine '
|
||||||
|
'widersprüchliche lokale '
|
||||||
|
'Definition; new '
|
||||||
|
'revision muss explizit '
|
||||||
|
'überprüft werden.',
|
||||||
|
'Das erneute Anwenden '
|
||||||
|
'eines identischen '
|
||||||
|
'Quell-Digests ist '
|
||||||
|
'idempotent und erzeugt '
|
||||||
|
'keine Revision.',
|
||||||
|
'Importierte '
|
||||||
|
'Definitionen erfordern '
|
||||||
|
'eine normale '
|
||||||
|
'Überprüfung und '
|
||||||
|
'Veröffentlichung von '
|
||||||
|
'Formularen vor der '
|
||||||
|
'Verwendung zur '
|
||||||
|
'Laufzeit.']}}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_forms.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
|
||||||
|
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.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
semantic_documentation_subject_capability,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_forms.backend.configuration_provider import FORMS_CONFIGURATION_CAPABILITY
|
||||||
|
from govoplan_forms.backend.db import models as form_models
|
||||||
|
from govoplan_forms.backend.dsar_provider import (
|
||||||
|
FORMS_DSAR_CAPABILITY,
|
||||||
|
FormsDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.service import SqlFormDefinitionProvider
|
||||||
|
from govoplan_forms.backend.semantic_subjects import (
|
||||||
|
FormsSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "forms"
|
||||||
|
MODULE_NAME = "Forms"
|
||||||
|
MODULE_VERSION = "0.1.23"
|
||||||
|
READ_SCOPE = "forms:definition:read"
|
||||||
|
WRITE_SCOPE = "forms:definition:write"
|
||||||
|
ADMIN_SCOPE = "forms:definition:admin"
|
||||||
|
OPTIONAL_DEPENDENCIES = (
|
||||||
|
"forms_runtime",
|
||||||
|
"portal",
|
||||||
|
"workflow_engine",
|
||||||
|
"cases",
|
||||||
|
"policy",
|
||||||
|
"docs",
|
||||||
|
)
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category=MODULE_NAME,
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _router(_context: ModuleContext):
|
||||||
|
from govoplan_forms.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _definitions(_context: ModuleContext) -> SqlFormDefinitionProvider:
|
||||||
|
return SqlFormDefinitionProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> FormsDsarProvider:
|
||||||
|
return FormsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_subjects(
|
||||||
|
_context: ModuleContext,
|
||||||
|
) -> FormsSemanticDocumentationSubjectProvider:
|
||||||
|
return FormsSemanticDocumentationSubjectProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_provider(_context: ModuleContext):
|
||||||
|
from govoplan_forms.backend.configuration_provider import (
|
||||||
|
SqlFormsConfigurationProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SqlFormsConfigurationProvider()
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name=MODULE_NAME,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("access",),
|
||||||
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name=FORMS_CONFIGURATION_CAPABILITY, version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name=FORMS_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=SEMANTIC_SUBJECT_CAPABILITY,
|
||||||
|
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(
|
||||||
|
READ_SCOPE,
|
||||||
|
"View form definitions",
|
||||||
|
"Read reusable form definitions and exact revisions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage form definitions",
|
||||||
|
"Create and revise reusable form definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Publish form definitions",
|
||||||
|
"Publish and retire form-definition revisions.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="forms_designer",
|
||||||
|
name="Forms designer",
|
||||||
|
description="Design and publish reusable form definitions.",
|
||||||
|
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="forms_reader",
|
||||||
|
name="Forms reader",
|
||||||
|
description="Inspect reusable form definitions.",
|
||||||
|
permissions=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/forms",
|
||||||
|
label="Form definitions",
|
||||||
|
icon="list-tree",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/forms-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/forms",
|
||||||
|
component="FormsPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/forms",
|
||||||
|
label="Form definitions",
|
||||||
|
icon="list-tree",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="services-cases",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
|
icon="landmark",
|
||||||
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
surface_ids=("forms.nav.forms", "forms.route.forms"),
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="forms.navigation",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="navigation",
|
||||||
|
label="Form definitions navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="forms.catalogue",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Form definition catalogue",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_FORM_DEFINITIONS: _definitions,
|
||||||
|
FORMS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
|
FORMS_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
|
||||||
|
label="Immutable form definitions",
|
||||||
|
summary="Resolves exact tenant-bound form schemas without exposing Forms tables.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
FORMS_CONFIGURATION_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Forms configuration-package provider",
|
||||||
|
summary="Preflights, imports, and exports immutable Form definition fragments as tenant-local drafts with source provenance.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("forms_designer", "system_admin", "operator"),
|
||||||
|
),
|
||||||
|
FORMS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Forms data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports minimized form-definition author attribution without schema "
|
||||||
|
"or semantic content."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Form semantic-documentation subjects",
|
||||||
|
summary=(
|
||||||
|
"Lists currently authorized form definitions, fields, and sections "
|
||||||
|
"using stable lineage identities and review fingerprints."
|
||||||
|
),
|
||||||
|
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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(
|
||||||
|
form_models.FormDefinitionRevision,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement removes immutable form-definition history and requires a database snapshot.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
form_models.FormDefinitionRevision,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms.semantic-documentation",
|
||||||
|
title="Document configured form and field meaning",
|
||||||
|
summary="Attach tenant-owned semantic guidance to an authorized form, field, or section without changing its schema.",
|
||||||
|
body=(
|
||||||
|
"When Docs is installed, Forms supplies documentation-safe subjects for each accessible current definition and its stable fields and sections. "
|
||||||
|
"The subject identity survives label and ordering changes. A deleted and later recreated key receives a new lineage identity, so old documentation remains explicitly orphaned instead of attaching silently. "
|
||||||
|
"Fingerprints change only when the relevant form, field, localization, hierarchy, validation, or visibility semantics change and request editorial review; they never publish or invalidate Docs content automatically. "
|
||||||
|
"Semantic prose can explain meaning, collection purpose, interpretation, intended and non-intended use, and examples, but cannot override field type, requiredness, constraints, validation, options, policy, or submitted values. "
|
||||||
|
"Forms rechecks tenant and read authority for discovery, direct resolution, contextual help, search, and Docs projection. If Docs is absent, form authoring and static help continue normally."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "form_designer", "information_owner", "module_admin"),
|
||||||
|
related_modules=("docs", "forms_runtime"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Semantic documentation authoring",
|
||||||
|
href="/docs/semantic",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Forms boundary and recovery",
|
||||||
|
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Konfigurierte Bedeutung von Formularen und Feldern dokumentieren",
|
||||||
|
"summary": (
|
||||||
|
"Mandanteneigene semantische Erläuterungen an ein berechtigtes Formular, Feld oder einen Abschnitt anfügen, ohne das "
|
||||||
|
"Schema zu verändern."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Ist Docs installiert, liefert Forms dokumentationssichere Subjekte für jede zugängliche aktuelle Definition sowie ihre "
|
||||||
|
"stabilen Felder und Abschnitte. Die Subjektidentität übersteht Änderungen an Bezeichnung und Reihenfolge. Ein gelöschter "
|
||||||
|
"und später neu angelegter Schlüssel erhält eine neue Abstammungsidentität; ältere Dokumentation bleibt ausdrücklich "
|
||||||
|
"verwaist, statt stillschweigend neu angefügt zu werden. Fingerabdrücke ändern sich nur, wenn sich relevante Formular-, "
|
||||||
|
"Feld-, Lokalisierungs-, Hierarchie-, Validierungs- oder Sichtbarkeitssemantik ändert, und fordern dann eine redaktionelle "
|
||||||
|
"Prüfung an; Docs-Inhalte werden niemals automatisch veröffentlicht oder entwertet. Semantischer Text darf Bedeutung, "
|
||||||
|
"Erhebungszweck, Interpretation, beabsichtigte und nicht beabsichtigte Verwendung sowie Beispiele erläutern, aber weder "
|
||||||
|
"Feldtyp, Pflichtstatus, Einschränkungen, Validierung, Optionen, Richtlinie noch übermittelte Werte überstimmen. Forms prüft "
|
||||||
|
"Mandant und Leseberechtigung für Ermittlung, direkte Auflösung, Kontexthilfe, Suche und Docs-Projektion erneut. Fehlt Docs, "
|
||||||
|
"funktionieren Formularerstellung und statische Hilfe unverändert weiter."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["forms.semantic-documentation"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms.data-subject-requests",
|
||||||
|
title="Form-definition data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Export definition-author activity without treating schemas as submitted values."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Forms correlates only an exact tenant account identifier and can narrow "
|
||||||
|
"an already verified search to one form or definition revision. It "
|
||||||
|
"returns the immutable revision identifier, lifecycle state, and timing "
|
||||||
|
"of the subject's definition work. Titles, search text, schema payloads, "
|
||||||
|
"field semantics, policy references, and change-reason content are not "
|
||||||
|
"included. Forms stores no submitted values; Forms Runtime and the "
|
||||||
|
"owning service export those records separately. Definition attribution "
|
||||||
|
"is retained with immutable schema history."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "forms_runtime", "docs", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen für Formulardefinitionen",
|
||||||
|
"summary": "Aktivität der Definitionsautoren exportieren, ohne Schemata als übermittelte Werte zu behandeln.",
|
||||||
|
"body": (
|
||||||
|
"Forms gleicht nur eine exakte mandantenbezogene Kontokennung ab und kann eine bereits verifizierte Suche auf ein "
|
||||||
|
"Formular oder eine Definitionsrevision begrenzen. Ausgegeben werden unveränderliche Revisionskennung, "
|
||||||
|
"Lebenszykluszustand und Zeitpunkte der Definitionsarbeit der betroffenen Person. Titel, Suchtext, Schemanutzdaten, "
|
||||||
|
"Feldsemantik, Richtlinienverweise und Inhalte von Änderungsgründen sind nicht enthalten. Forms speichert keine "
|
||||||
|
"übermittelten Werte; Forms Runtime und der zuständige Service exportieren diese Datensätze getrennt. Die Zuordnung der "
|
||||||
|
"Definitionsarbeit bleibt mit der unveränderlichen Schemahistorie erhalten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": ["forms.catalogue", "privacy.data-subject-requests"],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_definition_attribution": "Returns minimized immutable revision activity.",
|
||||||
|
"exclude_form_semantics": "Does not return schema, field, or submitted-value content.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms.definitions",
|
||||||
|
title="Reusable form definitions",
|
||||||
|
summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.",
|
||||||
|
body=(
|
||||||
|
"Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. "
|
||||||
|
"Publishing is explicit; existing submissions continue to retain their exact revision. The catalogue, filter, empty states, "
|
||||||
|
"section headings, and definition field groups use the shared responsive layout language and reflow at narrow widths without "
|
||||||
|
"changing filter scope, permissions, or lifecycle effects."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Forms boundary and recovery",
|
||||||
|
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Wiederverwendbare Formulardefinitionen erstellen",
|
||||||
|
"summary": (
|
||||||
|
"Unveränderliche versionierte Schemata erstellen, die Forms Runtime und institutionelle Services verwenden."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Jede Revision legt Feldtypen, Optionen, Einschränkungen sowie Anforderungen an Entwurf, Anlagen, Signaturen, "
|
||||||
|
"Richtlinien und Übergaben fest. Die Veröffentlichung erfolgt ausdrücklich; vorhandene Einreichungen behalten ihre "
|
||||||
|
"exakte Revision. Katalog, Filter, Leerzustände, Abschnittsüberschriften und Feldgruppen der Definition verwenden die "
|
||||||
|
"gemeinsame responsive Layoutsprache und ordnen sich bei geringer Breite neu an, ohne Filterumfang, Berechtigungen oder "
|
||||||
|
"Lebenszykluswirkungen zu verändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"forms.navigation",
|
||||||
|
"forms.catalogue",
|
||||||
|
"forms.state.permission-blocked",
|
||||||
|
"forms.state.empty",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Definition catalogues contain schemas and policy references, not submitted Form values.",
|
||||||
|
"Package assessment does not grant access to referenced runtime submissions or external providers.",
|
||||||
|
"Published accessibility and localization content is visible wherever the exact definition is authorized.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms.reference.fields-and-consequences",
|
||||||
|
title="Form definition fields and lifecycle consequences",
|
||||||
|
summary="Schema, publication, localization, policy, evidence, package, and handoff semantics for immutable Form revisions.",
|
||||||
|
body=(
|
||||||
|
"The stable key identifies the definition while each save creates a new immutable revision. Field keys, types, "
|
||||||
|
"constraints, visibility conditions, pages, sections, options, help, localization, and accessibility instructions "
|
||||||
|
"become the exact runtime schema. Attachment, signature, policy, draft, and permitted-handoff settings are enforced "
|
||||||
|
"by Forms Runtime when that module is present. Publishing makes a revision available for new instances; existing "
|
||||||
|
"instances retain their prior exact revision. Retirement prevents future use without deleting definitions or submissions. "
|
||||||
|
"Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision. "
|
||||||
|
"The Forms configuration provider validates the digest-bound source fragment, target tenant, operator authority, local conflicts, and replay provenance during preflight. The conservative conflict policy preserves an existing definition unless the reviewed package explicitly requests a new revision. Reapplying the same source digest is a no-op. Export emits only definition configuration and provenance; runtime submissions and submitted values remain outside the package."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Forms boundary and recovery",
|
||||||
|
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Felder und Lebenszyklusfolgen von Formulardefinitionen",
|
||||||
|
"summary": "Schema, Veröffentlichung, Lokalisierung, Richtlinien, Nachweise, Pakete und Übergaben unveränderlicher Formularrevisionen verstehen.",
|
||||||
|
"body": (
|
||||||
|
"Der stabile Schlüssel kennzeichnet die Definition; jedes Speichern erzeugt eine neue unveränderliche Revision. "
|
||||||
|
"Feldschlüssel, Typen, Einschränkungen, Sichtbarkeitsbedingungen, Seiten, Abschnitte, Auswahlwerte, Hilfen, Übersetzungen und Barrierefreiheitshinweise bilden das exakte Laufzeitschema. "
|
||||||
|
"Anhangs-, Signatur-, Richtlinien-, Entwurfs- und Übergabevorgaben werden durch Forms Runtime erzwungen, sofern das Modul vorhanden ist. "
|
||||||
|
"Die Veröffentlichung stellt eine Revision für neue Instanzen bereit; bestehende Instanzen behalten ihre genaue frühere Revision. Die Stilllegung verhindert künftige Nutzung, ohne Definitionen oder Einreichungen zu löschen. "
|
||||||
|
"Ein Paketimport erzeugt immer einen lokalen Entwurf, bewahrt die Herkunft und veröffentlicht niemals stillschweigend. "
|
||||||
|
"Der Forms-Konfigurationsprovider prüft vorab das digest-gebundene Quellfragment, den Zielmandanten, die Berechtigung, lokale Konflikte und Wiederholungsnachweise. "
|
||||||
|
"Die vorsichtige Konfliktregel erhält eine vorhandene Definition, sofern das geprüfte Paket nicht ausdrücklich eine neue Revision verlangt. Dieselbe Quelldigest erneut anzuwenden ist ein Leerlauf. "
|
||||||
|
"Der Export enthält ausschließlich Definitionskonfiguration und Herkunft; Laufzeiteinreichungen und eingegebene Werte bleiben außerhalb des Pakets."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"forms.field.publication-state",
|
||||||
|
"forms.field.signature-requirement",
|
||||||
|
"forms.field.policy-references",
|
||||||
|
"forms.field.handoff-kinds",
|
||||||
|
"forms.field.accessibility",
|
||||||
|
"forms.field.change-reason",
|
||||||
|
"forms.action.save-revision",
|
||||||
|
"forms.action.publish",
|
||||||
|
"forms.action.retire",
|
||||||
|
"forms.action.import-package",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"save_revision": "Creates an immutable definition revision with a change reason.",
|
||||||
|
"publish": "Makes the exact revision available for future authorized instances.",
|
||||||
|
"retire": "Stops future use while retaining definitions and exact runtime references.",
|
||||||
|
"import_package": "Creates a local draft and retains package provenance without automatic publication.",
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Package import never publishes a Form revision automatically.",
|
||||||
|
"Runtime submissions and submitted values are never exported as definition configuration.",
|
||||||
|
"Generic package rollback requires the retained pre-apply database snapshot.",
|
||||||
|
],
|
||||||
|
"operational_consequences": [
|
||||||
|
"Preserve blocks a conflicting local definition; new_revision must be explicitly reviewed.",
|
||||||
|
"Reapplying an identical source digest is idempotent and creates no revision.",
|
||||||
|
"Imported definitions require normal Forms review and publication before runtime use.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="human_work_procedure",
|
||||||
|
kind="domain",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/FORMS_BOUNDARY.md",
|
||||||
|
test_ref="tests/test_forms.py",
|
||||||
|
known_limits=(
|
||||||
|
"Concrete attachment/signature providers, anonymous identity profiles, and target-produced accessibility evidence remain product depth.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=("native_authoritative",),
|
||||||
|
owned_concepts=(
|
||||||
|
"form definition",
|
||||||
|
"form schema",
|
||||||
|
"form definition revision",
|
||||||
|
"form semantic subject identity and fingerprint",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"form submission",
|
||||||
|
"file content",
|
||||||
|
"case",
|
||||||
|
"workflow instance",
|
||||||
|
),
|
||||||
|
reference_packages=("product.service-to-decision",),
|
||||||
|
migration_docs=("docs/FORMS_BOUNDARY.md",),
|
||||||
|
recovery_docs=("docs/FORMS_BOUNDARY.md",),
|
||||||
|
security_docs=("docs/FORMS_BOUNDARY.md",),
|
||||||
|
operations_docs=("docs/FORMS_BOUNDARY.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Alembic revisions."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms migration versions."""
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
"""v0.1.14 immutable Forms definitions.
|
||||||
|
|
||||||
|
Revision ID: e1f2a3b4c5d6
|
||||||
|
Revises: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "e1f2a3b4c5d6"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_definition_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("form_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("form_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("publication_state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("search_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
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.PrimaryKeyConstraint("id", name=op.f("pk_form_definition_revisions")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"form_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_form_definition_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"form_id",
|
||||||
|
"form_key",
|
||||||
|
"revision",
|
||||||
|
"previous_revision_id",
|
||||||
|
"publication_state",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"changed_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_definition_revisions_{column}"),
|
||||||
|
"form_definition_revisions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_definition_current",
|
||||||
|
"form_definition_revisions",
|
||||||
|
["tenant_id", "form_id", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_definition_catalog",
|
||||||
|
"form_definition_revisions",
|
||||||
|
["tenant_id", "publication_state", "form_key"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_definition_revisions")
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.institutional import InstitutionalContextError
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_forms.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
|
||||||
|
from govoplan_forms.backend.schemas import (
|
||||||
|
FormDefinitionHistoryResponse,
|
||||||
|
FormDefinitionListResponse,
|
||||||
|
FormDefinitionWriteRequest,
|
||||||
|
FormPackageImportRequest,
|
||||||
|
FormPackageRequest,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.service import (
|
||||||
|
FormDefinitionStoreError,
|
||||||
|
definition_from_mapping,
|
||||||
|
assess_form_definition_fragment,
|
||||||
|
export_form_definition_fragment,
|
||||||
|
form_definition_diagnostics,
|
||||||
|
form_definition_history,
|
||||||
|
get_form_definition,
|
||||||
|
list_form_definitions,
|
||||||
|
import_form_definition_fragment,
|
||||||
|
record_form_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/forms", tags=["forms"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(exc: Exception) -> HTTPException:
|
||||||
|
message = str(exc)
|
||||||
|
code = (
|
||||||
|
409
|
||||||
|
if any(word in message.casefold() for word in ("conflict", "already", "stale"))
|
||||||
|
else 400
|
||||||
|
)
|
||||||
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions", response_model=FormDefinitionListResponse)
|
||||||
|
def api_list_form_definitions(
|
||||||
|
q: str = Query(default="", max_length=200),
|
||||||
|
publication_state: list[str] | None = Query(default=None),
|
||||||
|
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),
|
||||||
|
) -> FormDefinitionListResponse:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items, total = list_form_definitions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=q,
|
||||||
|
publication_states=publication_state,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except FormDefinitionStoreError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return FormDefinitionListResponse(
|
||||||
|
definitions=[item.to_dict() for item in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/definitions/{form_id}",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
def api_record_form_definition(
|
||||||
|
form_id: str,
|
||||||
|
payload: FormDefinitionWriteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
definition = definition_from_mapping(payload.definition)
|
||||||
|
if definition.reference.object_id != form_id:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Form definition path and payload IDs must match."
|
||||||
|
)
|
||||||
|
if definition.publication_state != "draft":
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
stored = record_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return stored.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions/{form_id}", response_model=dict[str, object])
|
||||||
|
def api_get_form_definition(
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = Query(default=None, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = get_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
form_id=form_id,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions/{form_id}/diagnostics", response_model=dict[str, object])
|
||||||
|
def api_form_definition_diagnostics(
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = Query(default=None, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||||
|
diagnostics = form_definition_diagnostics(item)
|
||||||
|
return {
|
||||||
|
"diagnostics": [dict(value) for value in diagnostics],
|
||||||
|
"error_count": sum(value.get("severity") == "error" for value in diagnostics),
|
||||||
|
"warning_count": sum(
|
||||||
|
value.get("severity") == "warning" for value in diagnostics
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions/{form_id}/package", response_model=dict[str, object])
|
||||||
|
def api_export_form_definition_package(
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = Query(default=None, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||||
|
actor_id = next(
|
||||||
|
(
|
||||||
|
str(getattr(principal, name))
|
||||||
|
for name in ("account_id", "identity_id", "membership_id")
|
||||||
|
if getattr(principal, name, None)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return export_form_definition_fragment(
|
||||||
|
item,
|
||||||
|
exported_at=datetime.now(UTC),
|
||||||
|
exported_by=actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/packages/assess", response_model=dict[str, object])
|
||||||
|
def api_assess_form_definition_package(
|
||||||
|
payload: FormPackageRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
return assess_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=payload.fragment,
|
||||||
|
)
|
||||||
|
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/packages/import", response_model=dict[str, object])
|
||||||
|
def api_import_form_definition_package(
|
||||||
|
payload: FormPackageImportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = import_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=payload.fragment,
|
||||||
|
target_form_id=payload.target_form_id,
|
||||||
|
target_key=payload.target_key,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/definitions/{form_id}/history",
|
||||||
|
response_model=FormDefinitionHistoryResponse,
|
||||||
|
)
|
||||||
|
def api_form_definition_history(
|
||||||
|
form_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FormDefinitionHistoryResponse:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
return FormDefinitionHistoryResponse(
|
||||||
|
revisions=[
|
||||||
|
item.to_dict()
|
||||||
|
for item in form_definition_history(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
form_id=form_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FormDefinitionWriteRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
definition: dict[str, Any]
|
||||||
|
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class FormDefinitionListResponse(BaseModel):
|
||||||
|
definitions: list[dict[str, Any]]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class FormDefinitionHistoryResponse(BaseModel):
|
||||||
|
revisions: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class FormPackageRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
fragment: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class FormPackageImportRequest(FormPackageRequest):
|
||||||
|
target_form_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
target_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormDefinitionHistoryResponse",
|
||||||
|
"FormDefinitionListResponse",
|
||||||
|
"FormDefinitionWriteRequest",
|
||||||
|
"FormPackageImportRequest",
|
||||||
|
"FormPackageRequest",
|
||||||
|
]
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
SemanticDocumentationBreadcrumb,
|
||||||
|
SemanticDocumentationSubjectAnchor,
|
||||||
|
SemanticDocumentationSubjectDescriptor,
|
||||||
|
SemanticDocumentationSubjectPage,
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
SemanticDocumentationSubjectReference,
|
||||||
|
SemanticDocumentationSubjectResolution,
|
||||||
|
semantic_documentation_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.service import definition_from_mapping
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECT_KIND = "form_definition"
|
||||||
|
READ_SCOPE = "forms:definition:read"
|
||||||
|
_MAX_SUBJECTS = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _IdentityState:
|
||||||
|
field_ids: Mapping[str, str]
|
||||||
|
section_ids: Mapping[tuple[str, str], str]
|
||||||
|
historical_field_ids: frozenset[str]
|
||||||
|
historical_section_ids: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
class FormsSemanticDocumentationSubjectProvider:
|
||||||
|
provider_id = "forms.semantic_subjects"
|
||||||
|
module_id = "forms"
|
||||||
|
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def list_subjects(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: SemanticDocumentationSubjectQuery,
|
||||||
|
) -> SemanticDocumentationSubjectPage:
|
||||||
|
if not _authorized(principal, request.tenant_id):
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
db = _session(session)
|
||||||
|
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
rows = (
|
||||||
|
db.query(FormDefinitionRevision)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == request.tenant_id,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(FormDefinitionRevision.form_key, FormDefinitionRevision.form_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
subjects: list[SemanticDocumentationSubjectDescriptor] = []
|
||||||
|
for row in rows:
|
||||||
|
definition = definition_from_mapping(row.payload)
|
||||||
|
identities = _identity_state(db, row)
|
||||||
|
subjects.extend(_descriptors(definition, identities))
|
||||||
|
if len(subjects) > _MAX_SUBJECTS:
|
||||||
|
raise ValueError(
|
||||||
|
"Forms semantic subject limit exceeded; narrow the query."
|
||||||
|
)
|
||||||
|
query = request.query.casefold().strip()
|
||||||
|
if query:
|
||||||
|
subjects = [
|
||||||
|
item
|
||||||
|
for item in subjects
|
||||||
|
if query in _descriptor_search_text(item).casefold()
|
||||||
|
]
|
||||||
|
offset = _cursor_offset(request.cursor)
|
||||||
|
selected = tuple(subjects[offset : offset + request.limit])
|
||||||
|
next_offset = offset + len(selected)
|
||||||
|
has_more = next_offset < len(subjects)
|
||||||
|
return SemanticDocumentationSubjectPage(
|
||||||
|
subjects=selected,
|
||||||
|
next_cursor=str(next_offset) if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: SemanticDocumentationSubjectReference,
|
||||||
|
) -> SemanticDocumentationSubjectResolution | None:
|
||||||
|
if (
|
||||||
|
reference.module_id != self.module_id
|
||||||
|
or reference.subject_kind != SUBJECT_KIND
|
||||||
|
or not _authorized(principal, reference.tenant_id)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
db = _session(session)
|
||||||
|
row = (
|
||||||
|
db.query(FormDefinitionRevision)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == reference.tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == reference.subject_id,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code="form_missing",
|
||||||
|
)
|
||||||
|
definition = definition_from_mapping(row.payload)
|
||||||
|
identities = _identity_state(db, row)
|
||||||
|
descriptor = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in _descriptors(definition, identities)
|
||||||
|
if item.reference.stable_key == reference.stable_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
anchor = reference.anchor
|
||||||
|
reason = "subject_missing"
|
||||||
|
if anchor is not None and anchor.kind == "field":
|
||||||
|
reason = (
|
||||||
|
"field_deleted"
|
||||||
|
if anchor.id in identities.historical_field_ids
|
||||||
|
else "field_missing"
|
||||||
|
)
|
||||||
|
elif anchor is not None and anchor.kind == "section":
|
||||||
|
reason = (
|
||||||
|
"section_deleted"
|
||||||
|
if anchor.id in identities.historical_section_ids
|
||||||
|
else "section_missing"
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code=reason,
|
||||||
|
)
|
||||||
|
changed = any(
|
||||||
|
expected is not None and expected != actual
|
||||||
|
for expected, actual in (
|
||||||
|
(reference.observed_revision, descriptor.reference.observed_revision),
|
||||||
|
(
|
||||||
|
reference.observed_fingerprint,
|
||||||
|
descriptor.reference.observed_fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="changed" if changed else "available",
|
||||||
|
subject=descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptors(definition, identities: _IdentityState):
|
||||||
|
form_reference = _reference(
|
||||||
|
definition,
|
||||||
|
revision=definition.temporal.revision,
|
||||||
|
fingerprint=_form_fingerprint(definition),
|
||||||
|
)
|
||||||
|
route = f"/forms?formId={quote(definition.reference.object_id, safe='')}"
|
||||||
|
form_labels = _form_labels(definition)
|
||||||
|
form_descriptions = _form_descriptions(definition)
|
||||||
|
result = [
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=form_reference,
|
||||||
|
labels=form_labels,
|
||||||
|
descriptions=form_descriptions,
|
||||||
|
route=route,
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
field_locations = _field_locations(definition)
|
||||||
|
for field in definition.fields:
|
||||||
|
identity = identities.field_ids[field.key]
|
||||||
|
page, section = field_locations.get(field.key, (None, None))
|
||||||
|
labels = _field_labels(definition, field.key, field.label)
|
||||||
|
descriptions = _field_descriptions(definition, field.key, field.help_text)
|
||||||
|
fingerprint = _field_fingerprint(definition, field, page, section, labels)
|
||||||
|
breadcrumbs = [
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=_label(form_labels),
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if page is not None:
|
||||||
|
breadcrumbs.append(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=page.title,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if section is not None:
|
||||||
|
breadcrumbs.append(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=section.title,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="section",
|
||||||
|
id=identities.section_ids[(page.key, section.key)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="field", id=identity
|
||||||
|
),
|
||||||
|
revision=fingerprint,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
),
|
||||||
|
labels=labels,
|
||||||
|
descriptions=descriptions,
|
||||||
|
breadcrumbs=tuple(breadcrumbs),
|
||||||
|
route=route,
|
||||||
|
route_anchor=f"field-{field.key}",
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for page in definition.pages:
|
||||||
|
for section in page.sections:
|
||||||
|
identity = identities.section_ids[(page.key, section.key)]
|
||||||
|
labels = _section_labels(definition, section.key, section.title)
|
||||||
|
fingerprint = _section_fingerprint(definition, page, section, labels)
|
||||||
|
result.append(
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="section", id=identity
|
||||||
|
),
|
||||||
|
revision=fingerprint,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
),
|
||||||
|
labels=labels,
|
||||||
|
descriptions=(
|
||||||
|
{_fallback_locale(definition): section.description}
|
||||||
|
if section.description
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
breadcrumbs=(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=_label(form_labels),
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
),
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=page.title,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
route=route,
|
||||||
|
route_anchor=f"section-{page.key}-{section.key}",
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(
|
||||||
|
definition,
|
||||||
|
*,
|
||||||
|
revision: str,
|
||||||
|
fingerprint: str,
|
||||||
|
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||||
|
) -> SemanticDocumentationSubjectReference:
|
||||||
|
return SemanticDocumentationSubjectReference(
|
||||||
|
module_id="forms",
|
||||||
|
tenant_id=definition.reference.tenant_id,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.reference.object_id,
|
||||||
|
anchor=anchor,
|
||||||
|
observed_revision=revision,
|
||||||
|
observed_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_state(
|
||||||
|
session: Session,
|
||||||
|
current: FormDefinitionRevision,
|
||||||
|
) -> _IdentityState:
|
||||||
|
rows = (
|
||||||
|
session.query(FormDefinitionRevision)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == current.tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == current.form_id,
|
||||||
|
)
|
||||||
|
.order_by(FormDefinitionRevision.recorded_at, FormDefinitionRevision.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active_fields: dict[str, str] = {}
|
||||||
|
active_sections: dict[tuple[str, str], str] = {}
|
||||||
|
historical_fields: set[str] = set()
|
||||||
|
historical_sections: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
definition = definition_from_mapping(row.payload)
|
||||||
|
field_keys = {field.key for field in definition.fields}
|
||||||
|
section_keys = {
|
||||||
|
(page.key, section.key)
|
||||||
|
for page in definition.pages
|
||||||
|
for section in page.sections
|
||||||
|
}
|
||||||
|
active_fields = {
|
||||||
|
key: value for key, value in active_fields.items() if key in field_keys
|
||||||
|
}
|
||||||
|
active_sections = {
|
||||||
|
key: value for key, value in active_sections.items() if key in section_keys
|
||||||
|
}
|
||||||
|
for key in sorted(field_keys):
|
||||||
|
active_fields.setdefault(key, _lineage_id("field", row.id, key))
|
||||||
|
historical_fields.add(active_fields[key])
|
||||||
|
for page_key, section_key in sorted(section_keys):
|
||||||
|
key = (page_key, section_key)
|
||||||
|
active_sections.setdefault(
|
||||||
|
key,
|
||||||
|
_lineage_id("section", row.id, page_key, section_key),
|
||||||
|
)
|
||||||
|
historical_sections.add(active_sections[key])
|
||||||
|
if row.id == current.id:
|
||||||
|
break
|
||||||
|
return _IdentityState(
|
||||||
|
field_ids=active_fields,
|
||||||
|
section_ids=active_sections,
|
||||||
|
historical_field_ids=frozenset(historical_fields),
|
||||||
|
historical_section_ids=frozenset(historical_sections),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_id(kind: str, *parts: str) -> str:
|
||||||
|
value = "\x1f".join((kind, *parts)).encode()
|
||||||
|
return f"{kind}-{hashlib.sha256(value).hexdigest()[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _form_fingerprint(definition) -> str:
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"revision": definition.temporal.revision,
|
||||||
|
"publication_state": definition.publication_state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_fingerprint(definition, field, page, section, labels) -> str:
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"canonical_label": field.label,
|
||||||
|
"canonical_help": field.help_text,
|
||||||
|
"labels": labels,
|
||||||
|
"help": _field_descriptions(definition, field.key, field.help_text),
|
||||||
|
"value_type": field.value_type,
|
||||||
|
"required": field.required,
|
||||||
|
"options": list(field.options),
|
||||||
|
"constraints": dict(field.constraints),
|
||||||
|
"visibility": (
|
||||||
|
field.visibility_condition.to_dict()
|
||||||
|
if field.visibility_condition is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"page": page.key if page else None,
|
||||||
|
"section": section.key if section else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_fingerprint(definition, page, section, labels) -> str:
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"labels": labels,
|
||||||
|
"description": section.description,
|
||||||
|
"page": page.key,
|
||||||
|
"field_keys": list(section.field_keys),
|
||||||
|
"visibility": (
|
||||||
|
section.visibility_condition.to_dict()
|
||||||
|
if section.visibility_condition is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_labels(definition) -> dict[str, str]:
|
||||||
|
labels = {_fallback_locale(definition): definition.title}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
if localization.title:
|
||||||
|
labels[localization.locale] = localization.title
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _form_descriptions(definition) -> dict[str, str]:
|
||||||
|
descriptions = (
|
||||||
|
{_fallback_locale(definition): definition.description}
|
||||||
|
if definition.description
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
for localization in definition.localizations:
|
||||||
|
if localization.description:
|
||||||
|
descriptions[localization.locale] = localization.description
|
||||||
|
return descriptions
|
||||||
|
|
||||||
|
|
||||||
|
def _field_labels(definition, key: str, canonical: str) -> dict[str, str]:
|
||||||
|
labels = {_fallback_locale(definition): canonical}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
label = localization.field_labels.get(key)
|
||||||
|
if label:
|
||||||
|
labels[localization.locale] = label
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _field_descriptions(
|
||||||
|
definition, key: str, canonical: str | None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
descriptions = (
|
||||||
|
{_fallback_locale(definition): canonical} if canonical else {}
|
||||||
|
)
|
||||||
|
for localization in definition.localizations:
|
||||||
|
value = localization.field_help_texts.get(key)
|
||||||
|
if value:
|
||||||
|
descriptions[localization.locale] = value
|
||||||
|
return descriptions
|
||||||
|
|
||||||
|
|
||||||
|
def _section_labels(definition, key: str, canonical: str) -> dict[str, str]:
|
||||||
|
labels = {_fallback_locale(definition): canonical}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
label = localization.section_titles.get(key)
|
||||||
|
if label:
|
||||||
|
labels[localization.locale] = label
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_locale(definition) -> str:
|
||||||
|
return definition.fallback_locale or "en"
|
||||||
|
|
||||||
|
|
||||||
|
def _field_locations(definition) -> dict[str, tuple[object, object]]:
|
||||||
|
return {
|
||||||
|
field_key: (page, section)
|
||||||
|
for page in definition.pages
|
||||||
|
for section in page.sections
|
||||||
|
for field_key in section.field_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _label(labels: Mapping[str, str]) -> str:
|
||||||
|
return labels.get("de") or labels.get("en") or next(iter(labels.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor_search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
|
||||||
|
return " ".join(
|
||||||
|
(
|
||||||
|
item.reference.subject_id,
|
||||||
|
*(item.labels.values()),
|
||||||
|
*(item.descriptions.values()),
|
||||||
|
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized(principal: object, tenant_id: str) -> bool:
|
||||||
|
if str(getattr(principal, "tenant_id", "") or "") != tenant_id:
|
||||||
|
return False
|
||||||
|
checker = getattr(principal, "has", None)
|
||||||
|
if callable(checker):
|
||||||
|
return bool(checker(READ_SCOPE))
|
||||||
|
return READ_SCOPE in getattr(principal, "scopes", ())
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(value: str | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if not value.isdigit() or int(value) < 0:
|
||||||
|
raise ValueError("Forms semantic subject cursor is invalid.")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Forms semantic subjects require a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormsSemanticDocumentationSubjectProvider",
|
||||||
|
"SUBJECT_KIND",
|
||||||
|
]
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
FormDefinition,
|
||||||
|
FormConditionExpression,
|
||||||
|
FormFieldDefinition,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
|
||||||
|
|
||||||
|
_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = {
|
||||||
|
"draft": frozenset({"draft", "published", "retired"}),
|
||||||
|
"published": frozenset({"published", "retired"}),
|
||||||
|
"retired": frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FormDefinitionStoreError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def definition_from_mapping(value: Mapping[str, object]) -> FormDefinition:
|
||||||
|
try:
|
||||||
|
return FormDefinition.from_mapping(value)
|
||||||
|
except InstitutionalContextError as exc:
|
||||||
|
raise FormDefinitionStoreError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def record_form_definition(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
definition: FormDefinition,
|
||||||
|
expected_revision: str | None = None,
|
||||||
|
) -> FormDefinition:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
_validate_definition(definition, tenant_id=tenant_id)
|
||||||
|
payload = definition.to_dict()
|
||||||
|
replay = (
|
||||||
|
session.query(FormDefinitionRevision)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == definition.reference.object_id,
|
||||||
|
FormDefinitionRevision.revision == definition.temporal.revision,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
if replay.payload != payload:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"A different Form definition already uses this revision."
|
||||||
|
)
|
||||||
|
return _definition_from_row(replay)
|
||||||
|
|
||||||
|
current = _current_row(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
form_id=definition.reference.object_id,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
if expected_revision is not None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Form definition revision conflict: no current revision exists."
|
||||||
|
)
|
||||||
|
key_collision = (
|
||||||
|
session.query(FormDefinitionRevision.id)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_key == definition.key,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if key_collision is not None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Form definition key is already in use in this tenant."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if expected_revision != current.revision:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Form definition revision conflict: the expected revision is stale."
|
||||||
|
)
|
||||||
|
if definition.key != current.form_key:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"A Form definition key cannot change across revisions."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
definition.publication_state
|
||||||
|
not in _PUBLICATION_TRANSITIONS[current.publication_state]
|
||||||
|
):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form publication transition {current.publication_state!r} to "
|
||||||
|
f"{definition.publication_state!r} is not allowed."
|
||||||
|
)
|
||||||
|
current.superseded_at = _recorded_at(definition)
|
||||||
|
|
||||||
|
row = FormDefinitionRevision(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
form_id=definition.reference.object_id,
|
||||||
|
form_key=definition.key,
|
||||||
|
revision=definition.temporal.revision,
|
||||||
|
previous_revision_id=current.id if current is not None else None,
|
||||||
|
publication_state=definition.publication_state,
|
||||||
|
title=definition.title,
|
||||||
|
recorded_at=_recorded_at(definition),
|
||||||
|
search_text=f"{definition.key} {definition.title} {definition.description or ''}".casefold(),
|
||||||
|
payload=payload,
|
||||||
|
changed_by=_principal_actor(principal),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
event_id=event_id,
|
||||||
|
type="forms.definition.recorded",
|
||||||
|
module_id="forms",
|
||||||
|
payload={
|
||||||
|
"form_id": row.form_id,
|
||||||
|
"form_key": row.form_key,
|
||||||
|
"revision": row.revision,
|
||||||
|
"publication_state": row.publication_state,
|
||||||
|
"field_count": len(definition.fields),
|
||||||
|
},
|
||||||
|
occurred_at=row.recorded_at,
|
||||||
|
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
||||||
|
tenant=EventTenantRef(id=tenant_id),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="form_definition",
|
||||||
|
id=row.form_id,
|
||||||
|
label=row.title,
|
||||||
|
),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _definition_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
def form_definition_diagnostics(
|
||||||
|
definition: FormDefinition,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Return deterministic, non-blocking authoring diagnostics.
|
||||||
|
|
||||||
|
Structural errors are rejected by ``_validate_definition``. Diagnostics are
|
||||||
|
reserved for useful publication quality feedback such as untranslated text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
diagnostics: list[Mapping[str, object]] = []
|
||||||
|
field_by_key = {item.key: item for item in definition.fields}
|
||||||
|
page_keys = {item.key for item in definition.pages}
|
||||||
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
locale = localization.locale
|
||||||
|
if not localization.title:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.title_missing",
|
||||||
|
f"{locale} does not translate the Form title.",
|
||||||
|
locale=locale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for field in definition.fields:
|
||||||
|
if field.key not in localization.field_labels:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.field_label_missing",
|
||||||
|
f"{locale} does not translate field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=field.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if field.help_text and field.key not in localization.field_help_texts:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.field_help_missing",
|
||||||
|
f"{locale} does not translate help for field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=field.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
translated_options = localization.option_labels.get(field.key, {})
|
||||||
|
for option in field.options:
|
||||||
|
if option not in translated_options:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.option_missing",
|
||||||
|
f"{locale} does not translate option {option!r} of field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=f"{field.key}:{option}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(page_keys - set(localization.page_titles)):
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.page_title_missing",
|
||||||
|
f"{locale} does not translate page {key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(section_keys - set(localization.section_titles)):
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.section_title_missing",
|
||||||
|
f"{locale} does not translate section {key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# These sets are validated structurally. Keeping the lookup here makes
|
||||||
|
# diagnostics stable if future compatible readers retain unknown keys.
|
||||||
|
_ = field_by_key
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def export_form_definition_fragment(
|
||||||
|
definition: FormDefinition,
|
||||||
|
*,
|
||||||
|
exported_at: datetime,
|
||||||
|
exported_by: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if exported_at.tzinfo is None or exported_at.utcoffset() is None:
|
||||||
|
raise FormDefinitionStoreError("Package exported_at must include a timezone.")
|
||||||
|
definition_payload = definition.to_dict()
|
||||||
|
digest = _payload_sha256(definition_payload)
|
||||||
|
return {
|
||||||
|
"kind": "govoplan.forms.definition",
|
||||||
|
"contract_version": "0.1.0",
|
||||||
|
"definition": definition_payload,
|
||||||
|
"definition_sha256": digest,
|
||||||
|
"provenance": {
|
||||||
|
"owner_module": "forms",
|
||||||
|
"tenant_id": definition.reference.tenant_id,
|
||||||
|
"form_id": definition.reference.object_id,
|
||||||
|
"revision": definition.reference.version,
|
||||||
|
"exported_at": exported_at.isoformat(),
|
||||||
|
"exported_by": exported_by,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_form_definition_fragment(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
fragment: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
source = _definition_from_fragment(fragment)
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
current = get_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
form_id=source.reference.object_id,
|
||||||
|
)
|
||||||
|
key_collision = (
|
||||||
|
session.query(FormDefinitionRevision.form_id)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_key == source.key,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
same_tenant = source.reference.tenant_id == tenant_id
|
||||||
|
if (
|
||||||
|
same_tenant
|
||||||
|
and current is not None
|
||||||
|
and current.reference.version == source.reference.version
|
||||||
|
):
|
||||||
|
outcome = (
|
||||||
|
"replay" if current.to_dict() == source.to_dict() else "revision_conflict"
|
||||||
|
)
|
||||||
|
elif current is not None:
|
||||||
|
outcome = "new_revision_required"
|
||||||
|
elif key_collision is not None:
|
||||||
|
outcome = "key_conflict"
|
||||||
|
else:
|
||||||
|
outcome = "create"
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"portable": True,
|
||||||
|
"same_tenant": same_tenant,
|
||||||
|
"source": {
|
||||||
|
"tenant_id": source.reference.tenant_id,
|
||||||
|
"form_id": source.reference.object_id,
|
||||||
|
"key": source.key,
|
||||||
|
"revision": source.reference.version,
|
||||||
|
},
|
||||||
|
"current_revision": current.reference.version if current else None,
|
||||||
|
"requires_remap": not same_tenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_form_definition_fragment(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
fragment: Mapping[str, object],
|
||||||
|
target_form_id: str | None,
|
||||||
|
target_key: str | None,
|
||||||
|
expected_revision: str | None,
|
||||||
|
change_reason: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormDefinition:
|
||||||
|
source = _definition_from_fragment(fragment)
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
if recorded_at.tzinfo is None or recorded_at.utcoffset() is None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Package import recorded_at must include a timezone."
|
||||||
|
)
|
||||||
|
clean_reason = str(change_reason or "").strip()
|
||||||
|
if not clean_reason or len(clean_reason) > 1000:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Package import requires a change reason of at most 1000 characters."
|
||||||
|
)
|
||||||
|
resolved_id = str(target_form_id or source.reference.object_id).strip()
|
||||||
|
resolved_key = str(target_key or source.key).strip()
|
||||||
|
current = get_form_definition(session, principal, form_id=resolved_id)
|
||||||
|
revision = str(uuid.uuid4())
|
||||||
|
payload = source.to_dict()
|
||||||
|
payload["reference"] = {
|
||||||
|
**dict(payload["reference"]),
|
||||||
|
"object_id": resolved_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"version": revision,
|
||||||
|
}
|
||||||
|
payload["key"] = current.key if current is not None else resolved_key
|
||||||
|
payload["temporal"] = {
|
||||||
|
"revision": revision,
|
||||||
|
"valid_from": recorded_at.isoformat(),
|
||||||
|
"valid_to": None,
|
||||||
|
"recorded_at": recorded_at.isoformat(),
|
||||||
|
"superseded_at": None,
|
||||||
|
"change_reason": clean_reason,
|
||||||
|
}
|
||||||
|
payload["publication_state"] = "draft"
|
||||||
|
metadata = dict(source.metadata)
|
||||||
|
metadata["package_import"] = {
|
||||||
|
"source_tenant_id": source.reference.tenant_id,
|
||||||
|
"source_form_id": source.reference.object_id,
|
||||||
|
"source_revision": source.reference.version,
|
||||||
|
"source_sha256": str(fragment.get("definition_sha256") or ""),
|
||||||
|
}
|
||||||
|
payload["metadata"] = metadata
|
||||||
|
imported = definition_from_mapping(payload)
|
||||||
|
return record_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=imported,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_form_definition(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = None,
|
||||||
|
) -> FormDefinition | None:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
query = session.query(FormDefinitionRevision).filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == form_id,
|
||||||
|
)
|
||||||
|
if revision is None:
|
||||||
|
query = query.filter(FormDefinitionRevision.superseded_at.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.filter(FormDefinitionRevision.revision == revision)
|
||||||
|
row = query.order_by(FormDefinitionRevision.recorded_at.desc()).first()
|
||||||
|
return _definition_from_row(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_form_definitions(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
publication_states: Sequence[str] | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[tuple[FormDefinition, ...], int]:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
if offset < 0 or not 1 <= limit <= 200:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Form definition offset must be non-negative and limit between 1 and 200."
|
||||||
|
)
|
||||||
|
statement = session.query(FormDefinitionRevision).filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
if publication_states:
|
||||||
|
statement = statement.filter(
|
||||||
|
FormDefinitionRevision.publication_state.in_(tuple(publication_states))
|
||||||
|
)
|
||||||
|
clean_query = query.strip().casefold()
|
||||||
|
if clean_query:
|
||||||
|
statement = statement.filter(
|
||||||
|
FormDefinitionRevision.search_text.contains(clean_query)
|
||||||
|
)
|
||||||
|
total = int(statement.with_entities(func.count()).scalar() or 0)
|
||||||
|
rows = (
|
||||||
|
statement.order_by(
|
||||||
|
FormDefinitionRevision.form_key.asc(),
|
||||||
|
FormDefinitionRevision.recorded_at.desc(),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return tuple(_definition_from_row(row) for row in rows), total
|
||||||
|
|
||||||
|
|
||||||
|
def form_definition_history(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
form_id: str,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[FormDefinition, ...]:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
if not 1 <= limit <= 200:
|
||||||
|
raise FormDefinitionStoreError("Form history limit must be between 1 and 200.")
|
||||||
|
rows = (
|
||||||
|
session.query(FormDefinitionRevision)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == form_id,
|
||||||
|
)
|
||||||
|
.order_by(FormDefinitionRevision.recorded_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return tuple(_definition_from_row(row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlFormDefinitionProvider:
|
||||||
|
def get_form_definition(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> FormDefinition | None:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
if (
|
||||||
|
reference.kind != "form"
|
||||||
|
or reference.owner_module != "forms"
|
||||||
|
or reference.tenant_id != tenant_id
|
||||||
|
or not reference.version
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form definition lookup requires an exact same-tenant Forms reference."
|
||||||
|
)
|
||||||
|
definition = get_form_definition(
|
||||||
|
_session(session),
|
||||||
|
principal,
|
||||||
|
form_id=reference.object_id,
|
||||||
|
revision=reference.version,
|
||||||
|
)
|
||||||
|
if definition is None or (
|
||||||
|
effective_at is not None
|
||||||
|
and not definition.temporal.effective_at(effective_at)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return definition
|
||||||
|
|
||||||
|
def list_form_definitions(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[FormDefinition]:
|
||||||
|
if tenant_id != _principal_tenant(principal):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form definition catalogue lookup cannot cross tenants."
|
||||||
|
)
|
||||||
|
items, _ = list_form_definitions(
|
||||||
|
_session(session),
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
publication_states=("published",),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _current_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
form_id: str,
|
||||||
|
lock: bool,
|
||||||
|
) -> FormDefinitionRevision | None:
|
||||||
|
query = session.query(FormDefinitionRevision).filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_id == form_id,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
return query.one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_from_row(row: FormDefinitionRevision) -> FormDefinition:
|
||||||
|
payload: dict[str, Any] = dict(row.payload)
|
||||||
|
temporal = dict(payload.get("temporal") or {})
|
||||||
|
temporal["superseded_at"] = _datetime_text(row.superseded_at)
|
||||||
|
payload["temporal"] = temporal
|
||||||
|
return FormDefinition.from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
|
||||||
|
if definition.reference.owner_module != "forms":
|
||||||
|
raise FormDefinitionStoreError("Form definitions must be owned by Forms.")
|
||||||
|
if definition.reference.tenant_id != tenant_id:
|
||||||
|
raise FormDefinitionStoreError("Form definitions cannot cross tenants.")
|
||||||
|
if definition.temporal.superseded_at is not None:
|
||||||
|
raise FormDefinitionStoreError("Clients cannot set Form superseded_at.")
|
||||||
|
_recorded_at(definition)
|
||||||
|
if not str(definition.temporal.change_reason or "").strip():
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"A Form definition revision requires a change reason."
|
||||||
|
)
|
||||||
|
_validate_form_composition(definition)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_form_composition(definition: FormDefinition) -> None:
|
||||||
|
fields = {item.key: item for item in definition.fields}
|
||||||
|
if definition.pages:
|
||||||
|
field_occurrences = [
|
||||||
|
field_key
|
||||||
|
for page in definition.pages
|
||||||
|
for section in page.sections
|
||||||
|
for field_key in section.field_keys
|
||||||
|
]
|
||||||
|
unknown = sorted(set(field_occurrences) - set(fields))
|
||||||
|
if unknown:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages reference unknown fields: {', '.join(unknown)}."
|
||||||
|
)
|
||||||
|
duplicates = sorted(
|
||||||
|
key for key in set(field_occurrences) if field_occurrences.count(key) > 1
|
||||||
|
)
|
||||||
|
if duplicates:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages place fields more than once: {', '.join(duplicates)}."
|
||||||
|
)
|
||||||
|
missing = sorted(set(fields) - set(field_occurrences))
|
||||||
|
if missing:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages do not place fields: {', '.join(missing)}."
|
||||||
|
)
|
||||||
|
conditions: list[tuple[str, FormConditionExpression]] = []
|
||||||
|
for field in definition.fields:
|
||||||
|
if field.visibility_condition is not None:
|
||||||
|
conditions.append((f"field:{field.key}", field.visibility_condition))
|
||||||
|
for page in definition.pages:
|
||||||
|
if page.visibility_condition is not None:
|
||||||
|
conditions.append((f"page:{page.key}", page.visibility_condition))
|
||||||
|
for section in page.sections:
|
||||||
|
if section.visibility_condition is not None:
|
||||||
|
conditions.append(
|
||||||
|
(f"section:{section.key}", section.visibility_condition)
|
||||||
|
)
|
||||||
|
for subject, condition in conditions:
|
||||||
|
_validate_condition(condition, fields=fields, subject=subject)
|
||||||
|
graph = {
|
||||||
|
field.key: set(field.visibility_condition.referenced_fields)
|
||||||
|
if field.visibility_condition is not None
|
||||||
|
else set()
|
||||||
|
for field in definition.fields
|
||||||
|
}
|
||||||
|
_reject_condition_cycles(graph)
|
||||||
|
page_keys = {item.key for item in definition.pages}
|
||||||
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
unknown_fields = (
|
||||||
|
set(localization.field_labels)
|
||||||
|
| set(localization.field_help_texts)
|
||||||
|
| set(localization.option_labels)
|
||||||
|
) - set(fields)
|
||||||
|
if unknown_fields:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown fields: "
|
||||||
|
f"{', '.join(sorted(unknown_fields))}."
|
||||||
|
)
|
||||||
|
if set(localization.page_titles) - page_keys:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown pages."
|
||||||
|
)
|
||||||
|
if set(localization.section_titles) - section_keys:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown sections."
|
||||||
|
)
|
||||||
|
for field_key, labels in localization.option_labels.items():
|
||||||
|
field = fields[field_key]
|
||||||
|
if set(labels) - set(field.options):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} translates unknown "
|
||||||
|
f"options for field {field_key!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_condition(
|
||||||
|
condition: FormConditionExpression,
|
||||||
|
*,
|
||||||
|
fields: Mapping[str, FormFieldDefinition],
|
||||||
|
subject: str,
|
||||||
|
) -> None:
|
||||||
|
if condition.kind != "predicate":
|
||||||
|
for child in condition.conditions:
|
||||||
|
_validate_condition(child, fields=fields, subject=subject)
|
||||||
|
return
|
||||||
|
field_key = str(condition.field_key)
|
||||||
|
field = fields.get(field_key)
|
||||||
|
if field is None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition on {subject} references unknown field {field_key!r}."
|
||||||
|
)
|
||||||
|
operator = str(condition.operator)
|
||||||
|
if operator in {"lt", "lte", "gt", "gte"} and field.value_type not in {
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
}:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator {operator!r} is incompatible with "
|
||||||
|
f"field {field_key!r} ({field.value_type})."
|
||||||
|
)
|
||||||
|
if operator == "contains" and field.value_type not in {
|
||||||
|
"text",
|
||||||
|
"multiline_text",
|
||||||
|
"email",
|
||||||
|
"multi_choice",
|
||||||
|
"list",
|
||||||
|
}:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator 'contains' is incompatible with field {field_key!r}."
|
||||||
|
)
|
||||||
|
if operator in {"in", "not_in"} and (
|
||||||
|
not isinstance(condition.value, Sequence)
|
||||||
|
or isinstance(condition.value, (str, bytes))
|
||||||
|
):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator {operator!r} requires a list value."
|
||||||
|
)
|
||||||
|
if operator not in {"is_empty", "is_not_empty", "in", "not_in"}:
|
||||||
|
if not _condition_value_matches(field, condition.value):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition value is incompatible with field {field_key!r} "
|
||||||
|
f"({field.value_type})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_value_matches(field: FormFieldDefinition, value: object) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if field.value_type == "boolean":
|
||||||
|
return isinstance(value, bool)
|
||||||
|
if field.value_type == "integer":
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
if field.value_type == "number":
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
if field.value_type in {"object"}:
|
||||||
|
return isinstance(value, Mapping)
|
||||||
|
if field.value_type in {"list", "multi_choice"}:
|
||||||
|
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
||||||
|
return isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_condition_cycles(graph: Mapping[str, set[str]]) -> None:
|
||||||
|
visiting: set[str] = set()
|
||||||
|
visited: set[str] = set()
|
||||||
|
|
||||||
|
def visit(key: str, path: tuple[str, ...]) -> None:
|
||||||
|
if key in visiting:
|
||||||
|
cycle = " -> ".join((*path, key))
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form visibility conditions contain a dependency cycle: {cycle}."
|
||||||
|
)
|
||||||
|
if key in visited:
|
||||||
|
return
|
||||||
|
visiting.add(key)
|
||||||
|
for dependency in sorted(graph.get(key, set())):
|
||||||
|
if dependency in graph:
|
||||||
|
visit(dependency, (*path, key))
|
||||||
|
visiting.remove(key)
|
||||||
|
visited.add(key)
|
||||||
|
|
||||||
|
for key in sorted(graph):
|
||||||
|
visit(key, ())
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_from_fragment(fragment: Mapping[str, object]) -> FormDefinition:
|
||||||
|
if fragment.get("kind") != "govoplan.forms.definition":
|
||||||
|
raise FormDefinitionStoreError("Unsupported Forms package fragment kind.")
|
||||||
|
if fragment.get("contract_version") != "0.1.0":
|
||||||
|
raise FormDefinitionStoreError("Unsupported Forms package contract version.")
|
||||||
|
payload = fragment.get("definition")
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise FormDefinitionStoreError("Forms package definition must be an object.")
|
||||||
|
expected = str(fragment.get("definition_sha256") or "")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", expected) or not _constant_time_equal(
|
||||||
|
expected,
|
||||||
|
_payload_sha256(payload),
|
||||||
|
):
|
||||||
|
raise FormDefinitionStoreError("Forms package definition digest is invalid.")
|
||||||
|
return definition_from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_sha256(value: Mapping[str, object]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _constant_time_equal(left: str, right: str) -> bool:
|
||||||
|
return hmac.compare_digest(left, right)
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_diagnostic(
|
||||||
|
severity: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
locale: str | None = None,
|
||||||
|
subject: str | None = None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"severity": severity,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"locale": locale,
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_at(definition: FormDefinition) -> datetime:
|
||||||
|
if definition.temporal.recorded_at is None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"A Form definition revision requires recorded_at."
|
||||||
|
)
|
||||||
|
return definition.temporal.recorded_at
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_tenant(principal: object) -> str:
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
|
if not tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form definition operations require a tenant-bound principal."
|
||||||
|
)
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor(principal: object) -> str | None:
|
||||||
|
for name in ("account_id", "identity_id", "membership_id"):
|
||||||
|
value = str(getattr(principal, name, "") or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not hasattr(value, "query"):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form definition provider requires a database session."
|
||||||
|
)
|
||||||
|
return value # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_text(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=UTC)
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormDefinitionStoreError",
|
||||||
|
"SqlFormDefinitionProvider",
|
||||||
|
"definition_from_mapping",
|
||||||
|
"form_definition_history",
|
||||||
|
"get_form_definition",
|
||||||
|
"list_form_definitions",
|
||||||
|
"record_form_definition",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.configuration_packages import (
|
||||||
|
ConfigurationPackageFragment,
|
||||||
|
ConfigurationPreflightContext,
|
||||||
|
ConfigurationProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
FormDefinition,
|
||||||
|
FormFieldDefinition,
|
||||||
|
FormLocalization,
|
||||||
|
FormPageDefinition,
|
||||||
|
FormSectionDefinition,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.configuration_provider import (
|
||||||
|
FORMS_CONFIGURATION_CAPABILITY,
|
||||||
|
SqlFormsConfigurationProvider,
|
||||||
|
_apply_definition,
|
||||||
|
_preflight_definition,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.manifest import get_manifest
|
||||||
|
from govoplan_forms.backend.service import (
|
||||||
|
export_form_definition_fragment,
|
||||||
|
get_form_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str = "operator-1"
|
||||||
|
|
||||||
|
|
||||||
|
def source_definition(title: str = "Resident parking permit") -> FormDefinition:
|
||||||
|
return FormDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id="resident-parking-permit-application",
|
||||||
|
tenant_id="reference-package",
|
||||||
|
version="3",
|
||||||
|
),
|
||||||
|
key="resident-parking-permit-application",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="3",
|
||||||
|
recorded_at=datetime(2026, 8, 22, tzinfo=UTC),
|
||||||
|
change_reason="Reference package revision.",
|
||||||
|
),
|
||||||
|
title=title,
|
||||||
|
description="Apply for a resident parking permit through a digital or assisted channel.",
|
||||||
|
fields=(
|
||||||
|
FormFieldDefinition(key="applicant_name", label="Name", required=True, constraints={"min_length": 2, "max_length": 200}),
|
||||||
|
FormFieldDefinition(key="applicant_email", label="Email", required=True, constraints={"format": "email"}),
|
||||||
|
FormFieldDefinition(key="residence_address", label="Primary residence", required=True, constraints={"max_length": 500}),
|
||||||
|
FormFieldDefinition(key="licence_plate", label="Licence plate", required=True, constraints={"max_length": 20}),
|
||||||
|
),
|
||||||
|
publication_state="published",
|
||||||
|
max_attachments=4,
|
||||||
|
policy_refs=("law:resident-parking-permit", "records:resident-parking-permit"),
|
||||||
|
handoff_kinds=("case", "workflow"),
|
||||||
|
pages=(
|
||||||
|
FormPageDefinition(
|
||||||
|
key="application",
|
||||||
|
title="Application",
|
||||||
|
sections=(
|
||||||
|
FormSectionDefinition(
|
||||||
|
key="applicant-and-vehicle",
|
||||||
|
title="Applicant and vehicle",
|
||||||
|
field_keys=("applicant_name", "applicant_email", "residence_address", "licence_plate"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fallback_locale="de",
|
||||||
|
localizations=(
|
||||||
|
FormLocalization(
|
||||||
|
locale="de",
|
||||||
|
title="Anwohnerparkausweis beantragen",
|
||||||
|
description="Einen Anwohnerparkausweis digital oder mit Unterstützung beantragen.",
|
||||||
|
field_labels={
|
||||||
|
"applicant_name": "Name",
|
||||||
|
"applicant_email": "E-Mail-Adresse",
|
||||||
|
"residence_address": "Hauptwohnsitz",
|
||||||
|
"licence_plate": "Kennzeichen",
|
||||||
|
},
|
||||||
|
page_titles={"application": "Antrag"},
|
||||||
|
section_titles={"applicant-and-vehicle": "Antragstellende Person und Fahrzeug"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsConfigurationProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormDefinitionRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.context = ConfigurationPreflightContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
operator_user_id="operator-1",
|
||||||
|
operator_scopes=frozenset({"system:governance:write"}),
|
||||||
|
installed_modules={"forms": "0.1.20"},
|
||||||
|
capabilities=frozenset({FORMS_CONFIGURATION_CAPABILITY}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def fragment(self, definition: FormDefinition | None = None, *, on_conflict: str = "new_revision") -> ConfigurationPackageFragment:
|
||||||
|
return ConfigurationPackageFragment(
|
||||||
|
module_id="forms",
|
||||||
|
fragment_type="definition",
|
||||||
|
fragment_id="resident-parking-permit-application",
|
||||||
|
payload={
|
||||||
|
"fragment": export_form_definition_fragment(
|
||||||
|
definition or source_definition(),
|
||||||
|
exported_at=datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||||||
|
exported_by="package-author",
|
||||||
|
),
|
||||||
|
"on_conflict": on_conflict,
|
||||||
|
"change_reason": "Install the reviewed resident parking permit reference form.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_provider_is_registered_and_runtime_checkable(self) -> None:
|
||||||
|
provider = get_manifest().capability_factories[FORMS_CONFIGURATION_CAPABILITY](None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, ConfigurationProvider)
|
||||||
|
self.assertIsInstance(provider, SqlFormsConfigurationProvider)
|
||||||
|
self.assertEqual(("definition",), provider.describe().fragment_types)
|
||||||
|
|
||||||
|
def test_import_is_tenant_local_draft_and_same_source_replay_is_noop(self) -> None:
|
||||||
|
fragment = self.fragment()
|
||||||
|
|
||||||
|
preflight = _preflight_definition(self.session, fragment, self.context)
|
||||||
|
applied = _apply_definition(self.session, fragment, self.context)
|
||||||
|
self.session.commit()
|
||||||
|
replay_preflight = _preflight_definition(self.session, fragment, self.context)
|
||||||
|
replay = _apply_definition(self.session, fragment, self.context)
|
||||||
|
|
||||||
|
self.assertEqual("create", preflight.plan[0].action)
|
||||||
|
self.assertEqual(1, len(applied.created_refs))
|
||||||
|
imported = get_form_definition(
|
||||||
|
self.session,
|
||||||
|
Principal("tenant-1"),
|
||||||
|
form_id="resident-parking-permit-application",
|
||||||
|
)
|
||||||
|
assert imported is not None
|
||||||
|
self.assertEqual("tenant-1", imported.reference.tenant_id)
|
||||||
|
self.assertEqual("draft", imported.publication_state)
|
||||||
|
self.assertEqual(
|
||||||
|
"reference-package",
|
||||||
|
imported.metadata["package_import"]["source_tenant_id"],
|
||||||
|
)
|
||||||
|
self.assertEqual("noop", replay_preflight.plan[0].action)
|
||||||
|
self.assertEqual({}, replay.created_refs)
|
||||||
|
self.assertEqual({}, replay.updated_refs)
|
||||||
|
|
||||||
|
def test_preserve_reports_conflict_and_missing_authority_blocks(self) -> None:
|
||||||
|
first = self.fragment()
|
||||||
|
_apply_definition(self.session, first, self.context)
|
||||||
|
self.session.commit()
|
||||||
|
changed = self.fragment(source_definition("Changed reference"), on_conflict="preserve")
|
||||||
|
unauthorized = ConfigurationPreflightContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
operator_user_id="operator-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
conflict = _preflight_definition(self.session, changed, self.context)
|
||||||
|
denied = _preflight_definition(self.session, changed, unauthorized)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"forms_configuration_conflict",
|
||||||
|
{item.code for item in conflict.diagnostics},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"forms_configuration_write_scope_required",
|
||||||
|
{item.code for item in denied.diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.dsar_provider import (
|
||||||
|
FORMS_DSAR_CAPABILITY,
|
||||||
|
FormsDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 22, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsDsarProviderTests(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 = FormsDsarProvider()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
FormDefinitionRevision(
|
||||||
|
id="revision-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
form_id="form-1",
|
||||||
|
form_key="secret-form-key-do-not-export",
|
||||||
|
revision="2",
|
||||||
|
publication_state="published",
|
||||||
|
title="Sensitive semantic title do not export",
|
||||||
|
recorded_at=NOW,
|
||||||
|
search_text="search-content-do-not-export",
|
||||||
|
payload={"secret": "schema-payload-do-not-export"},
|
||||||
|
changed_by="account-1",
|
||||||
|
),
|
||||||
|
FormDefinitionRevision(
|
||||||
|
id="revision-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
form_id="form-other",
|
||||||
|
form_key="other",
|
||||||
|
revision="1",
|
||||||
|
publication_state="draft",
|
||||||
|
title="Other tenant",
|
||||||
|
recorded_at=NOW,
|
||||||
|
search_text="other",
|
||||||
|
payload={},
|
||||||
|
changed_by="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_search_is_minimized_tenant_safe_and_narrowable(self) -> None:
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=subject
|
||||||
|
)
|
||||||
|
self.assertEqual(["revision-1"], [record.resource_id for record in records])
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
for excluded in (
|
||||||
|
"secret-form-key-do-not-export",
|
||||||
|
"Sensitive semantic title do not export",
|
||||||
|
"search-content-do-not-export",
|
||||||
|
"schema-payload-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(excluded, exported)
|
||||||
|
narrowed = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"forms.form": "form-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(narrowed))
|
||||||
|
|
||||||
|
def test_requires_account_and_retains_definition_history(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(email="designer@example.test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subject = DsarSubjectRef(account_id="account-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.assertTrue(all(action.kind == "retain" for action in actions))
|
||||||
|
|
||||||
|
def test_manifest_registers_provider_and_documentation(self) -> None:
|
||||||
|
self.assertIn(FORMS_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
"forms.data-subject-requests",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
FormDefinition,
|
||||||
|
FormConditionExpression,
|
||||||
|
FormFieldDefinition,
|
||||||
|
FormLocalization,
|
||||||
|
FormPageDefinition,
|
||||||
|
FormSectionDefinition,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.service import (
|
||||||
|
FormDefinitionStoreError,
|
||||||
|
SqlFormDefinitionProvider,
|
||||||
|
assess_form_definition_fragment,
|
||||||
|
export_form_definition_fragment,
|
||||||
|
form_definition_diagnostics,
|
||||||
|
form_definition_history,
|
||||||
|
import_form_definition_fragment,
|
||||||
|
record_form_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
|
||||||
|
|
||||||
|
def definition(
|
||||||
|
*,
|
||||||
|
revision: str = "1",
|
||||||
|
state: str = "published",
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
) -> FormDefinition:
|
||||||
|
recorded_at = NOW + timedelta(minutes=int(revision) - 1)
|
||||||
|
return FormDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id="permit-application",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
version=revision,
|
||||||
|
),
|
||||||
|
key="permit-application",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision=revision,
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
change_reason="Initial schema." if revision == "1" else "Revise schema.",
|
||||||
|
),
|
||||||
|
title="Permit application",
|
||||||
|
fields=(
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="name",
|
||||||
|
label="Name",
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 2, "max_length": 200},
|
||||||
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="delivery",
|
||||||
|
label="Delivery",
|
||||||
|
value_type="choice",
|
||||||
|
options=("portal", "mail"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
publication_state=state, # type: ignore[arg-type]
|
||||||
|
allow_drafts=True,
|
||||||
|
max_attachments=2,
|
||||||
|
handoff_kinds=("case",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormDefinitionRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_provider_returns_exact_published_revision_and_history(self) -> None:
|
||||||
|
first = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(),
|
||||||
|
)
|
||||||
|
provider = SqlFormDefinitionProvider()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(first,),
|
||||||
|
tuple(
|
||||||
|
provider.list_form_definitions(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
exact = provider.get_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=first.reference,
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual("1", exact.temporal.revision if exact else None)
|
||||||
|
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(revision="2"),
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["2", "1"],
|
||||||
|
[
|
||||||
|
item.temporal.revision
|
||||||
|
for item in form_definition_history(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
form_id="permit-application",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replay_occ_and_tenant_boundaries_fail_closed(self) -> None:
|
||||||
|
first = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(),
|
||||||
|
)
|
||||||
|
replay = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(),
|
||||||
|
)
|
||||||
|
self.assertEqual(first, replay)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "stale"):
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(revision="2"),
|
||||||
|
expected_revision="0",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "cross tenants"):
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(tenant_id="tenant-2"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(Exception, "cross tenants"):
|
||||||
|
SqlFormDefinitionProvider().list_form_definitions(
|
||||||
|
self.session,
|
||||||
|
Principal("tenant-2"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pages_conditions_and_localization_are_validated(self) -> None:
|
||||||
|
item = definition()
|
||||||
|
composed = replace(
|
||||||
|
item,
|
||||||
|
fields=(
|
||||||
|
item.fields[0],
|
||||||
|
replace(
|
||||||
|
item.fields[1],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="name",
|
||||||
|
operator="is_not_empty",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pages=(
|
||||||
|
FormPageDefinition(
|
||||||
|
key="application",
|
||||||
|
title="Application",
|
||||||
|
sections=(
|
||||||
|
FormSectionDefinition(
|
||||||
|
key="details",
|
||||||
|
title="Details",
|
||||||
|
field_keys=("name", "delivery"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fallback_locale="de",
|
||||||
|
localizations=(
|
||||||
|
FormLocalization(
|
||||||
|
locale="de",
|
||||||
|
title="Antrag",
|
||||||
|
field_labels={"name": "Name", "delivery": "Zustellung"},
|
||||||
|
option_labels={"delivery": {"portal": "Portal", "mail": "Post"}},
|
||||||
|
page_titles={"application": "Antrag"},
|
||||||
|
section_titles={"details": "Angaben"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stored = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=composed,
|
||||||
|
)
|
||||||
|
self.assertEqual("application", stored.pages[0].key)
|
||||||
|
self.assertEqual((), form_definition_diagnostics(stored))
|
||||||
|
|
||||||
|
cyclic = replace(
|
||||||
|
definition(revision="2"),
|
||||||
|
fields=(
|
||||||
|
replace(
|
||||||
|
item.fields[0],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="delivery",
|
||||||
|
operator="eq",
|
||||||
|
value="portal",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
replace(
|
||||||
|
item.fields[1],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="name",
|
||||||
|
operator="is_not_empty",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "dependency cycle"):
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=cyclic,
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_package_fragment_is_verified_assessed_and_imported_as_draft(self) -> None:
|
||||||
|
source = definition(tenant_id="tenant-source")
|
||||||
|
fragment = export_form_definition_fragment(
|
||||||
|
source,
|
||||||
|
exported_at=NOW,
|
||||||
|
exported_by="source-account",
|
||||||
|
)
|
||||||
|
assessment = assess_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=fragment,
|
||||||
|
)
|
||||||
|
self.assertEqual("create", assessment["outcome"])
|
||||||
|
self.assertTrue(assessment["requires_remap"])
|
||||||
|
|
||||||
|
imported = import_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=fragment,
|
||||||
|
target_form_id="local-permit",
|
||||||
|
target_key="local-permit",
|
||||||
|
expected_revision=None,
|
||||||
|
change_reason="Import reviewed package.",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual("tenant-1", imported.reference.tenant_id)
|
||||||
|
self.assertEqual("draft", imported.publication_state)
|
||||||
|
self.assertEqual(
|
||||||
|
"tenant-source",
|
||||||
|
imported.metadata["package_import"]["source_tenant_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
tampered = dict(fragment)
|
||||||
|
tampered["definition"] = {**fragment["definition"], "title": "Tampered"}
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "digest"):
|
||||||
|
assess_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=tampered,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
(assess_form_definition_fragment,)
|
||||||
|
(export_form_definition_fragment,)
|
||||||
|
(form_definition_diagnostics,)
|
||||||
|
(import_form_definition_fragment,)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_forms.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
semantic_documentation_subject_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual({"/forms"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{"forms.navigation", "forms.catalogue"},
|
||||||
|
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_help_privacy_and_consequence_metadata_remain_published(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
guide = topics["forms.definitions"]
|
||||||
|
reference = topics["forms.reference.fields-and-consequences"]
|
||||||
|
self.assertIn("forms.catalogue", guide.metadata["help_contexts"])
|
||||||
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
|
self.assertEqual("workflow", guide.metadata["kind"])
|
||||||
|
self.assertIn(
|
||||||
|
"forms.field.publication-state", reference.metadata["help_contexts"]
|
||||||
|
)
|
||||||
|
self.assertIn("publish", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("import_package", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
def test_semantic_subject_provider_and_static_baseline_are_declared(self) -> None:
|
||||||
|
capability = semantic_documentation_subject_capability("forms")
|
||||||
|
self.assertIn("docs", manifest.optional_dependencies)
|
||||||
|
self.assertNotIn("docs", manifest.dependencies)
|
||||||
|
self.assertIn(capability, manifest.capability_factories)
|
||||||
|
self.assertEqual(
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
manifest.capability_documentation[capability].contract_version,
|
||||||
|
)
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
semantic = topics["forms.semantic-documentation"]
|
||||||
|
self.assertEqual({"admin", "user"}, set(semantic.documentation_types))
|
||||||
|
self.assertIn("cannot override", semantic.body)
|
||||||
|
|
||||||
|
def test_builder_links_semantic_help_without_closing_unsaved_dialog(self) -> None:
|
||||||
|
source = (
|
||||||
|
Path(__file__).parents[1]
|
||||||
|
/ "webui/src/features/forms/FormDefinitionDialog.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn('target="_blank"', source)
|
||||||
|
self.assertIn("semanticFieldDocumentation", source)
|
||||||
|
self.assertIn("/docs/semantic?", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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_forms.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
class FormsMigrationTests(unittest.TestCase):
|
||||||
|
def test_fresh_migration_creates_definition_store_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-forms-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'forms.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("forms",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
self.assertIn(
|
||||||
|
"form_definition_revisions",
|
||||||
|
inspect(engine).get_table_names(),
|
||||||
|
)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"e1f2a3b4c5d6",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
FormDefinition,
|
||||||
|
FormFieldDefinition,
|
||||||
|
FormLocalization,
|
||||||
|
FormPageDefinition,
|
||||||
|
FormSectionDefinition,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.semantic_subjects import (
|
||||||
|
FormsSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.service import record_form_definition
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 8, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "author-1"
|
||||||
|
scopes: frozenset[str] = frozenset({"forms:definition:read"})
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
def definition(
|
||||||
|
revision: int,
|
||||||
|
*,
|
||||||
|
fields: tuple[FormFieldDefinition, ...] | None = None,
|
||||||
|
) -> FormDefinition:
|
||||||
|
resolved_fields = fields or (
|
||||||
|
FormFieldDefinition(key="name", label="Name", required=True),
|
||||||
|
FormFieldDefinition(key="delivery", label="Delivery method"),
|
||||||
|
)
|
||||||
|
return FormDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id="resident-permit",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=str(revision),
|
||||||
|
),
|
||||||
|
key="resident-permit",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision=str(revision),
|
||||||
|
recorded_at=NOW + timedelta(minutes=revision),
|
||||||
|
change_reason=f"Revision {revision}",
|
||||||
|
),
|
||||||
|
title="Resident permit",
|
||||||
|
fields=resolved_fields,
|
||||||
|
publication_state="published",
|
||||||
|
pages=(
|
||||||
|
FormPageDefinition(
|
||||||
|
key="application",
|
||||||
|
title="Application",
|
||||||
|
sections=(
|
||||||
|
FormSectionDefinition(
|
||||||
|
key="details",
|
||||||
|
title="Applicant details",
|
||||||
|
field_keys=tuple(field.key for field in resolved_fields),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fallback_locale="de",
|
||||||
|
localizations=(
|
||||||
|
FormLocalization(
|
||||||
|
locale="de",
|
||||||
|
title="Anwohnerparkausweis",
|
||||||
|
field_labels={field.key: field.label for field in resolved_fields},
|
||||||
|
page_titles={"application": "Antrag"},
|
||||||
|
section_titles={"details": "Angaben"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsSemanticSubjectTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormDefinitionRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
self.provider = FormsSemanticDocumentationSubjectProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def subjects(self):
|
||||||
|
return self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1", limit=200),
|
||||||
|
).subjects
|
||||||
|
|
||||||
|
def test_exposes_safe_form_field_and_section_descriptors(self) -> None:
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(1),
|
||||||
|
)
|
||||||
|
subjects = self.subjects()
|
||||||
|
self.assertEqual(4, len(subjects))
|
||||||
|
form = next(item for item in subjects if item.reference.anchor is None)
|
||||||
|
field = next(
|
||||||
|
item
|
||||||
|
for item in subjects
|
||||||
|
if item.reference.anchor and item.reference.anchor.kind == "field"
|
||||||
|
)
|
||||||
|
self.assertEqual("Anwohnerparkausweis", form.labels["de"])
|
||||||
|
self.assertEqual("forms:definition:read", field.required_scopes[0])
|
||||||
|
self.assertTrue(field.route.startswith("/forms?formId="))
|
||||||
|
self.assertTrue(field.route_anchor.startswith("field-"))
|
||||||
|
self.assertNotIn("constraints", field.to_dict())
|
||||||
|
|
||||||
|
def test_identity_survives_reorder_and_label_change_but_fingerprint_changes(self) -> None:
|
||||||
|
first = definition(1)
|
||||||
|
record_form_definition(self.session, self.principal, definition=first)
|
||||||
|
before = {
|
||||||
|
item.route_anchor: item.reference
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.reference.anchor and item.reference.anchor.kind == "field"
|
||||||
|
}
|
||||||
|
revised = replace(
|
||||||
|
definition(2),
|
||||||
|
fields=(
|
||||||
|
first.fields[1],
|
||||||
|
replace(first.fields[0], label="Full legal name"),
|
||||||
|
),
|
||||||
|
pages=(
|
||||||
|
FormPageDefinition(
|
||||||
|
key="application",
|
||||||
|
title="Application",
|
||||||
|
sections=(
|
||||||
|
FormSectionDefinition(
|
||||||
|
key="details",
|
||||||
|
title="Applicant details",
|
||||||
|
field_keys=("delivery", "name"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=revised,
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
after = {
|
||||||
|
item.route_anchor: item.reference
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.reference.anchor and item.reference.anchor.kind == "field"
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
before["field-name"].stable_key,
|
||||||
|
after["field-name"].stable_key,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
before["field-name"].observed_fingerprint,
|
||||||
|
after["field-name"].observed_fingerprint,
|
||||||
|
)
|
||||||
|
resolution = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=before["field-name"],
|
||||||
|
)
|
||||||
|
self.assertEqual("changed", resolution.availability)
|
||||||
|
|
||||||
|
def test_deleted_and_recreated_key_gets_new_lineage(self) -> None:
|
||||||
|
first = definition(1)
|
||||||
|
record_form_definition(self.session, self.principal, definition=first)
|
||||||
|
old = next(
|
||||||
|
item.reference
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.route_anchor == "field-delivery"
|
||||||
|
)
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(2, fields=(first.fields[0],)),
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("field_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
recreated_field = replace(first.fields[1], label="Recreated delivery")
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(3, fields=(first.fields[0], recreated_field)),
|
||||||
|
expected_revision="2",
|
||||||
|
)
|
||||||
|
recreated = next(
|
||||||
|
item.reference
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.route_anchor == "field-delivery"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(old.stable_key, recreated.stable_key)
|
||||||
|
still_deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("field_deleted", still_deleted.reason_code)
|
||||||
|
|
||||||
|
def test_resolution_denies_cross_tenant_and_missing_scope(self) -> None:
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition(1),
|
||||||
|
)
|
||||||
|
reference = self.subjects()[0].reference
|
||||||
|
denied = replace(self.principal, scopes=frozenset())
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
denied,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
foreign = replace(self.principal, tenant_id="tenant-2")
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
foreign,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
denied,
|
||||||
|
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||||
|
).subjects,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/forms-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/forms.css": "./src/styles/forms.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,153 @@
|
|||||||
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
||||||
|
|
||||||
|
export type FormCondition =
|
||||||
|
| { kind: "predicate"; field_key: string; operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "is_empty" | "is_not_empty"; value?: unknown }
|
||||||
|
| { kind: "all" | "any"; conditions: FormCondition[] }
|
||||||
|
| { kind: "not"; conditions: [FormCondition] };
|
||||||
|
|
||||||
|
export type FormFieldDefinition = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value_type: FormValueType;
|
||||||
|
required: boolean;
|
||||||
|
help_text?: string | null;
|
||||||
|
options: string[];
|
||||||
|
constraints: Record<string, unknown>;
|
||||||
|
default_value?: unknown;
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
accessibility?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormSectionDefinition = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
field_keys: string[];
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormPageDefinition = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
sections: FormSectionDefinition[];
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormLocalization = {
|
||||||
|
locale: string;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
field_labels: Record<string, string>;
|
||||||
|
field_help_texts: Record<string, string>;
|
||||||
|
option_labels: Record<string, Record<string, string>>;
|
||||||
|
page_titles: Record<string, string>;
|
||||||
|
section_titles: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormDefinition = {
|
||||||
|
reference: {
|
||||||
|
kind: "form";
|
||||||
|
owner_module: "forms";
|
||||||
|
object_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
key: string;
|
||||||
|
temporal: {
|
||||||
|
revision: string;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_to?: string | null;
|
||||||
|
recorded_at: string;
|
||||||
|
superseded_at?: string | null;
|
||||||
|
change_reason: string;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
fields: FormFieldDefinition[];
|
||||||
|
publication_state: "draft" | "published" | "retired";
|
||||||
|
allow_drafts: boolean;
|
||||||
|
max_attachments: number;
|
||||||
|
signature_requirement: "none" | "optional" | "required";
|
||||||
|
policy_refs: string[];
|
||||||
|
handoff_kinds: string[];
|
||||||
|
pages?: FormPageDefinition[];
|
||||||
|
fallback_locale?: string | null;
|
||||||
|
localizations?: FormLocalization[];
|
||||||
|
accessibility?: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormPackageFragment = {
|
||||||
|
kind: "govoplan.forms.definition";
|
||||||
|
contract_version: "0.1.0";
|
||||||
|
definition: FormDefinition;
|
||||||
|
definition_sha256: string;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listFormDefinitions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { query?: string; states?: string[]; offset?: number; limit?: number } = {},
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ definitions: FormDefinition[]; total: number }> {
|
||||||
|
return apiFetch(settings, apiPath("/api/v1/forms/definitions", {
|
||||||
|
q: options.query,
|
||||||
|
publication_state: options.states,
|
||||||
|
offset: options.offset ?? 0,
|
||||||
|
limit: options.limit ?? 200
|
||||||
|
}), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFormDefinition(
|
||||||
|
settings: ApiSettings,
|
||||||
|
definition: FormDefinition,
|
||||||
|
expectedRevision?: string | null
|
||||||
|
): Promise<FormDefinition> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms/definitions/${encodeURIComponent(definition.reference.object_id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
definition,
|
||||||
|
expected_revision: expectedRevision ?? null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
formId: string,
|
||||||
|
revision: string
|
||||||
|
): Promise<FormPackageFragment> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/forms/definitions/${encodeURIComponent(formId)}/package`, { revision }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
fragment: FormPackageFragment
|
||||||
|
): Promise<{ outcome: string; requires_remap: boolean; current_revision?: string | null }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms/packages/assess", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ fragment })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
fragment: FormPackageFragment,
|
||||||
|
options: { targetFormId?: string; targetKey?: string; expectedRevision?: string; changeReason: string }
|
||||||
|
): Promise<FormDefinition> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms/packages/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fragment,
|
||||||
|
target_form_id: options.targetFormId || null,
|
||||||
|
target_key: options.targetKey || null,
|
||||||
|
expected_revision: options.expectedRevision || null,
|
||||||
|
change_reason: options.changeReason,
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
import { ArrowDown, ArrowUp, BookOpen, Eye, Languages, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { FormGrid,
|
||||||
|
ActionToolbar,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField as Field,
|
||||||
|
IconButton,
|
||||||
|
ToggleSwitch,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings,
|
||||||
|
type DocumentationHelpReference
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
saveFormDefinition,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormCondition,
|
||||||
|
type FormFieldDefinition,
|
||||||
|
type FormLocalization,
|
||||||
|
type FormPageDefinition,
|
||||||
|
type FormValueType
|
||||||
|
} from "../../api/forms";
|
||||||
|
import { FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "multiline_text", label: "Long text" },
|
||||||
|
{ value: "email", label: "Email" },
|
||||||
|
{ value: "integer", label: "Integer" },
|
||||||
|
{ value: "number", label: "Number" },
|
||||||
|
{ value: "boolean", label: "Yes / no" },
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "datetime", label: "Date and time" },
|
||||||
|
{ value: "choice", label: "Single choice" },
|
||||||
|
{ value: "multi_choice", label: "Multiple choice" },
|
||||||
|
{ value: "object", label: "Structured object" },
|
||||||
|
{ value: "list", label: "Structured list" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FormDefinitionDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
tenantId,
|
||||||
|
definition,
|
||||||
|
canPublish,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: ApiSettings;
|
||||||
|
tenantId: string;
|
||||||
|
definition: FormDefinition | null;
|
||||||
|
canPublish: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (definition: FormDefinition) => void;
|
||||||
|
}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const [baseline, setBaseline] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
|
||||||
|
const [draft, setDraft] = useState<FormDefinition>(baseline);
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [confirmLifecycle, setConfirmLifecycle] = useState(false);
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const next = initialDraft(tenantId, definition);
|
||||||
|
setDraft(next);
|
||||||
|
setBaseline(next);
|
||||||
|
setChangeReason("");
|
||||||
|
setBusy(false);
|
||||||
|
setError("");
|
||||||
|
setConfirmLifecycle(false);
|
||||||
|
}, [definition, open, tenantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !definition || !window.location.hash) return;
|
||||||
|
const targetId = decodeURIComponent(window.location.hash.slice(1));
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
document.getElementById(targetId)?.scrollIntoView({ block: "center" });
|
||||||
|
});
|
||||||
|
}, [definition, open]);
|
||||||
|
|
||||||
|
const valid = useMemo(() => Boolean(
|
||||||
|
draft.title.trim()
|
||||||
|
&& draft.key.trim()
|
||||||
|
&& draft.fields.length > 0
|
||||||
|
&& draft.fields.every((field) => field.key.trim() && field.label.trim())
|
||||||
|
&& changeReason.trim()
|
||||||
|
), [changeReason, draft]);
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => Boolean(changeReason || JSON.stringify(draft) !== JSON.stringify(baseline)),
|
||||||
|
[baseline, changeReason, draft]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!valid) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
const recordedAt = new Date().toISOString();
|
||||||
|
const payload: FormDefinition = {
|
||||||
|
...draft,
|
||||||
|
reference: {
|
||||||
|
...draft.reference,
|
||||||
|
version: revision
|
||||||
|
},
|
||||||
|
temporal: {
|
||||||
|
...draft.temporal,
|
||||||
|
revision,
|
||||||
|
recorded_at: recordedAt,
|
||||||
|
superseded_at: null,
|
||||||
|
change_reason: changeReason.trim()
|
||||||
|
},
|
||||||
|
title: draft.title.trim(),
|
||||||
|
key: draft.key.trim(),
|
||||||
|
description: draft.description?.trim() || null,
|
||||||
|
fields: draft.fields.map(normalizeField),
|
||||||
|
policy_refs: draft.policy_refs.map((item) => item.trim()).filter(Boolean),
|
||||||
|
metadata: { ...draft.metadata }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const saved = await saveFormDefinition(
|
||||||
|
settings,
|
||||||
|
payload,
|
||||||
|
definition?.reference.version
|
||||||
|
);
|
||||||
|
onSaved(saved);
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form definition could not be saved.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: open && dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => {
|
||||||
|
setDraft(baseline);
|
||||||
|
setChangeReason("");
|
||||||
|
},
|
||||||
|
title: "i18n:govoplan-forms.unsaved_title",
|
||||||
|
message: "i18n:govoplan-forms.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSave() {
|
||||||
|
const previousState = definition?.publication_state ?? "draft";
|
||||||
|
if (draft.publication_state !== "draft" && draft.publication_state !== previousState) {
|
||||||
|
setConfirmLifecycle(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field),
|
||||||
|
pages: patch.key && patch.key !== current.fields[index].key
|
||||||
|
? remapPageField(current.pages ?? [], current.fields[index].key, patch.key)
|
||||||
|
: current.pages
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveField(index: number, delta: -1 | 1) {
|
||||||
|
setDraft((current) => {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= current.fields.length) return current;
|
||||||
|
const fields = [...current.fields];
|
||||||
|
[fields[index], fields[target]] = [fields[target], fields[index]];
|
||||||
|
return { ...current, fields };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={definition ? `Revise ${definition.title}` : "New Form definition"}
|
||||||
|
onClose={requestClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
portal
|
||||||
|
className="form-definition-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={requestClose} disabled={busy} disabledReason={busy ? FORMS_I18N.busy : undefined}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={requestSave} disabled={busy || !valid} disabledReason={busy ? FORMS_I18N.busy : !valid ? FORMS_I18N.incomplete : undefined}>
|
||||||
|
{busy ? "Saving" : "Save revision"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<div className="form-definition-editor">
|
||||||
|
<div className="form-definition-help">
|
||||||
|
<DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} />
|
||||||
|
{definition && <>
|
||||||
|
<DocumentationHelpLink reference={semanticFormDocumentation(definition)} />
|
||||||
|
<a className="btn btn-secondary" href={semanticAuthoringHref(definition)} target="_blank" rel="noreferrer">
|
||||||
|
<BookOpen size={16} aria-hidden="true" /> Document form meaning
|
||||||
|
</a>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
|
<Field label="Title">
|
||||||
|
<input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Key">
|
||||||
|
<input value={draft.key} disabled={busy || Boolean(definition)} onChange={(event) => setDraft({ ...draft, key: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Description" className="form-definition-wide">
|
||||||
|
<textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Publication state" help={!canPublish ? FORMS_I18N.adminReason : undefined} documentation={FORMS_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.publication_state} disabled={busy} onChange={(event) => setDraft({ ...draft, publication_state: event.target.value as FormDefinition["publication_state"] })}>
|
||||||
|
{definition?.publication_state !== "published" && <option value="draft">Draft</option>}
|
||||||
|
{canPublish && <option value="published">Published</option>}
|
||||||
|
{canPublish && <option value="retired">Retired</option>}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Signature" documentation={FORMS_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.signature_requirement} disabled={busy} onChange={(event) => setDraft({ ...draft, signature_requirement: event.target.value as FormDefinition["signature_requirement"] })}>
|
||||||
|
<option value="none">Not used</option>
|
||||||
|
<option value="optional">Optional</option>
|
||||||
|
<option value="required">Required</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Maximum attachments">
|
||||||
|
<input type="number" min={0} max={1000} value={draft.max_attachments} disabled={busy} onChange={(event) => setDraft({ ...draft, max_attachments: Number(event.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<div className="form-definition-toggle">
|
||||||
|
<ToggleSwitch label="Draft saving" checked={draft.allow_drafts} disabled={busy} onChange={(allow_drafts) => setDraft({ ...draft, allow_drafts })} />
|
||||||
|
</div>
|
||||||
|
<Field label="Policy references" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
|
||||||
|
<input value={draft.policy_refs.join(", ")} disabled={busy} placeholder="policy:permit-intake" onChange={(event) => setDraft({ ...draft, policy_refs: splitValues(event.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<div className="form-definition-handoffs form-definition-wide">
|
||||||
|
<span>Permitted handoffs</span>
|
||||||
|
{(["case", "workflow", "record"] as const).map((kind) =>
|
||||||
|
<ToggleSwitch
|
||||||
|
key={kind}
|
||||||
|
label={humanize(kind)}
|
||||||
|
checked={draft.handoff_kinds.includes(kind)}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(checked) => setDraft({
|
||||||
|
...draft,
|
||||||
|
handoff_kinds: checked
|
||||||
|
? [...draft.handoff_kinds, kind]
|
||||||
|
: draft.handoff_kinds.filter((item) => item !== kind)
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Field label="Accessibility instructions" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={String(draft.accessibility?.instructions ?? "")}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setDraft({
|
||||||
|
...draft,
|
||||||
|
accessibility: patchOptionalText(draft.accessibility ?? {}, "instructions", event.target.value)
|
||||||
|
})}
|
||||||
|
placeholder="Optional instructions announced before the Form"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
|
||||||
|
<ActionToolbar surface="section-header" className="form-field-editor-heading">
|
||||||
|
<h3>Fields</h3>
|
||||||
|
<Button onClick={() => setDraft(addField(draft))} disabled={busy}>
|
||||||
|
<Plus size={16} aria-hidden="true" />Add field
|
||||||
|
</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
<div className="form-field-editor-list">
|
||||||
|
{draft.fields.map((field, index) =>
|
||||||
|
<div className="form-field-editor-row" id={`field-${field.key}`} key={`${index}:${field.key}`}>
|
||||||
|
<div className="form-field-order">
|
||||||
|
<IconButton label={`Move ${field.label || "field"} up`} icon={<ArrowUp size={15} />} disabled={busy || index === 0} onClick={() => moveField(index, -1)} />
|
||||||
|
<IconButton label={`Move ${field.label || "field"} down`} icon={<ArrowDown size={15} />} disabled={busy || index === draft.fields.length - 1} onClick={() => moveField(index, 1)} />
|
||||||
|
</div>
|
||||||
|
<Field label="Key"><input value={field.key} disabled={busy} onChange={(event) => patchField(index, { key: event.target.value })} /></Field>
|
||||||
|
<Field
|
||||||
|
label="Label"
|
||||||
|
documentation={definition && definition.fields.some((item) => item.key === field.key) ? semanticFieldDocumentation(definition, field.key) : FORMS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
|
<input value={field.label} disabled={busy} onChange={(event) => patchField(index, { label: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Type">
|
||||||
|
<select value={field.value_type} disabled={busy} onChange={(event) => patchField(index, { value_type: event.target.value as FormValueType, options: isChoice(event.target.value) ? field.options : [] })}>
|
||||||
|
{VALUE_TYPES.map((type) => <option key={type.value} value={type.value}>{type.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="form-field-required"><ToggleSwitch label="Required" checked={field.required} disabled={busy} onChange={(required) => patchField(index, { required })} /></div>
|
||||||
|
<Field label="Help text" className="form-field-help"><input value={field.help_text ?? ""} disabled={busy} onChange={(event) => patchField(index, { help_text: event.target.value })} /></Field>
|
||||||
|
{isChoice(field.value_type) &&
|
||||||
|
<Field label="Options" className="form-field-options"><input value={field.options.join(", ")} disabled={busy} onChange={(event) => patchField(index, { options: splitValues(event.target.value) })} /></Field>
|
||||||
|
}
|
||||||
|
<ConditionFields
|
||||||
|
condition={field.visibility_condition ?? null}
|
||||||
|
fields={draft.fields}
|
||||||
|
currentKey={field.key}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(visibility_condition) => patchField(index, { visibility_condition })}
|
||||||
|
/>
|
||||||
|
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
|
||||||
|
<IconButton
|
||||||
|
label={`Remove ${field.label || "field"}`}
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
variant="danger"
|
||||||
|
disabled={busy || draft.fields.length === 1}
|
||||||
|
disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined}
|
||||||
|
onClick={() => setDraft(removeField(draft, index))}
|
||||||
|
/>
|
||||||
|
{definition && definition.fields.some((item) => item.key === field.key) && (
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(definition, `field-${field.key}`)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={15} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PageEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||||
|
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||||
|
<DefinitionPreview definition={draft} previous={definition} />
|
||||||
|
<Field label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}>
|
||||||
|
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmLifecycle}
|
||||||
|
title="i18n:govoplan-forms.lifecycle_title"
|
||||||
|
message={i18nMessage("i18n:govoplan-forms.lifecycle_message", { state: translateText(humanize(draft.publication_state)) })}
|
||||||
|
confirmLabel="Save revision"
|
||||||
|
tone={draft.publication_state === "retired" ? "danger" : "default"}
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmLifecycle(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmLifecycle(false);
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefinition; disabled: boolean; onChange: (value: Record<string, unknown>) => void }) {
|
||||||
|
if (["text", "multiline_text", "email"].includes(field.value_type)) {
|
||||||
|
return (
|
||||||
|
<FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-constraints">
|
||||||
|
<Field label="Minimum length"><input type="number" min={0} value={constraintValue(field.constraints.min_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "min_length", event.target.value))} /></Field>
|
||||||
|
<Field label="Maximum length"><input type="number" min={0} value={constraintValue(field.constraints.max_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "max_length", event.target.value))} /></Field>
|
||||||
|
<Field label="Pattern"><input value={String(field.constraints.pattern ?? "")} disabled={disabled} onChange={(event) => onChange(patchTextConstraint(field.constraints, "pattern", event.target.value))} /></Field>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (["integer", "number"].includes(field.value_type)) {
|
||||||
|
return (
|
||||||
|
<FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-constraints">
|
||||||
|
<Field label="Minimum"><input type="number" value={constraintValue(field.constraints.minimum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "minimum", event.target.value))} /></Field>
|
||||||
|
<Field label="Maximum"><input type="number" value={constraintValue(field.constraints.maximum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "maximum", event.target.value))} /></Field>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionFields({
|
||||||
|
condition,
|
||||||
|
fields,
|
||||||
|
currentKey,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
condition: FormCondition | null;
|
||||||
|
fields: FormFieldDefinition[];
|
||||||
|
currentKey: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: FormCondition | null) => void;
|
||||||
|
}) {
|
||||||
|
const predicate = condition?.kind === "predicate" ? condition : null;
|
||||||
|
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
|
||||||
|
return (
|
||||||
|
<FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-condition">
|
||||||
|
<Field label="Visible when">
|
||||||
|
<select
|
||||||
|
value={predicate?.field_key ?? ""}
|
||||||
|
disabled={disabled || candidates.length === 0}
|
||||||
|
onChange={(event) => onChange(event.target.value
|
||||||
|
? { kind: "predicate", field_key: event.target.value, operator: "eq", value: true }
|
||||||
|
: null)}>
|
||||||
|
<option value="">Always visible</option>
|
||||||
|
{candidates.map((item) => <option key={item.key} value={item.key}>{item.label || item.key}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{predicate && <>
|
||||||
|
<Field label="Condition">
|
||||||
|
<select
|
||||||
|
value={predicate.operator}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
...predicate,
|
||||||
|
operator: event.target.value as Extract<FormCondition, { kind: "predicate" }>["operator"],
|
||||||
|
...(event.target.value === "is_empty" || event.target.value === "is_not_empty" ? { value: undefined } : {})
|
||||||
|
})}>
|
||||||
|
<option value="eq">Equals</option>
|
||||||
|
<option value="neq">Does not equal</option>
|
||||||
|
<option value="is_empty">Is empty</option>
|
||||||
|
<option value="is_not_empty">Is not empty</option>
|
||||||
|
<option value="contains">Contains</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{!(["is_empty", "is_not_empty"] as string[]).includes(predicate.operator) &&
|
||||||
|
<Field label="Value">
|
||||||
|
<input
|
||||||
|
value={conditionInputValue(predicate.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange({ ...predicate, value: parseConditionValue(event.target.value, fields.find((item) => item.key === predicate.field_key)?.value_type) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
}
|
||||||
|
</>}
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||||
|
const pages = draft.pages ?? [];
|
||||||
|
function updatePages(next: FormPageDefinition[]) {
|
||||||
|
onChange({ ...draft, pages: next });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="form-composition-section">
|
||||||
|
<ActionToolbar surface="section-header" className="form-field-editor-heading">
|
||||||
|
<h3>Pages and sections</h3>
|
||||||
|
{pages.length === 0
|
||||||
|
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
|
||||||
|
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
|
||||||
|
</ActionToolbar>
|
||||||
|
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
|
||||||
|
{pages.map((page, pageIndex) =>
|
||||||
|
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
|
||||||
|
<div className="form-page-editor-heading">
|
||||||
|
<Field label="Page key"><input value={page.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, key: event.target.value }))} /></Field>
|
||||||
|
<Field label="Page title"><input value={page.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, title: event.target.value }))} /></Field>
|
||||||
|
<IconButton label={`Remove page ${page.title || page.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => updatePages(pages.filter((_, index) => index !== pageIndex))} />
|
||||||
|
</div>
|
||||||
|
{page.sections.map((section, sectionIndex) =>
|
||||||
|
<div className="form-section-editor" key={`${sectionIndex}:${section.key}`}>
|
||||||
|
<Field label="Section key"><input value={section.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, key: event.target.value }) }))} /></Field>
|
||||||
|
<Field label="Section title"><input value={section.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, title: event.target.value }) }))} /></Field>
|
||||||
|
<Field label="Fields">
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={section.field_keys}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => updatePages(replaceAt(pages, pageIndex, {
|
||||||
|
...page,
|
||||||
|
sections: replaceAt(page.sections, sectionIndex, {
|
||||||
|
...section,
|
||||||
|
field_keys: Array.from(event.currentTarget.selectedOptions, (option) => option.value)
|
||||||
|
})
|
||||||
|
}))}>
|
||||||
|
{draft.fields.map((field) => <option key={field.key} value={field.key}>{field.label || field.key}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<IconButton label={`Remove section ${section.title || section.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled || page.sections.length === 1} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: page.sections.filter((_, index) => index !== sectionIndex) }))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button disabled={disabled} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: [...page.sections, emptySection(page.sections.length + 1)] }))}><Plus size={15} aria-hidden="true" />Add section</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||||
|
const localizations = draft.localizations ?? [];
|
||||||
|
function update(items: FormLocalization[]) {
|
||||||
|
const fallback = items.some((item) => item.locale === draft.fallback_locale)
|
||||||
|
? draft.fallback_locale
|
||||||
|
: items[0]?.locale ?? null;
|
||||||
|
onChange({ ...draft, localizations: items, fallback_locale: fallback });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="form-composition-section">
|
||||||
|
<ActionToolbar surface="section-header" className="form-field-editor-heading">
|
||||||
|
<h3><Languages size={17} aria-hidden="true" />Localizations</h3>
|
||||||
|
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
|
||||||
|
{localizations.map((localization, index) =>
|
||||||
|
<div className="form-localization-editor" key={`${index}:${localization.locale}`}>
|
||||||
|
<Field label="Locale"><input value={localization.locale} disabled={disabled} placeholder="de" onChange={(event) => update(replaceAt(localizations, index, { ...localization, locale: event.target.value }))} /></Field>
|
||||||
|
<Field label="Localized title"><input value={localization.title ?? ""} disabled={disabled} onChange={(event) => update(replaceAt(localizations, index, { ...localization, title: event.target.value }))} /></Field>
|
||||||
|
<label className="form-localization-fallback"><input type="radio" checked={draft.fallback_locale === localization.locale} disabled={disabled || !localization.locale} onChange={() => onChange({ ...draft, fallback_locale: localization.locale })} />Fallback</label>
|
||||||
|
<IconButton label={`Remove locale ${localization.locale || index + 1}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => update(localizations.filter((_, itemIndex) => itemIndex !== index))} />
|
||||||
|
<div className="form-localization-fields">
|
||||||
|
{draft.fields.map((field) =>
|
||||||
|
<Field key={field.key} label={`${field.label || field.key} label`}>
|
||||||
|
<input
|
||||||
|
value={localization.field_labels[field.key] ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => update(replaceAt(localizations, index, {
|
||||||
|
...localization,
|
||||||
|
field_labels: patchOptionalText(localization.field_labels, field.key, event.target.value)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefinitionPreview({ definition, previous }: { definition: FormDefinition; previous: FormDefinition | null }) {
|
||||||
|
const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
|
||||||
|
return (
|
||||||
|
<section className="form-composition-section form-definition-preview">
|
||||||
|
<ActionToolbar surface="section-header" className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></ActionToolbar>
|
||||||
|
<FormGrid columns={2} collapseAt="narrow" className="form-preview-grid">
|
||||||
|
<div>
|
||||||
|
<strong>{definition.title || "Untitled Form"}</strong>
|
||||||
|
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
|
||||||
|
<div key={page.key} className="form-preview-page">
|
||||||
|
<span>{page.title}</span>
|
||||||
|
{page.sections.map((section) => <small key={section.key}>{section.title}: {section.field_keys.join(", ") || "No fields"}</small>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
|
||||||
|
</FormGrid>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
|
||||||
|
if (definition) return structuredClone(definition);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
reference: { kind: "form", owner_module: "forms", object_id: id, tenant_id: tenantId, version: revision },
|
||||||
|
key: "",
|
||||||
|
temporal: { revision, recorded_at: now, change_reason: "" },
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
fields: [emptyField(1)],
|
||||||
|
publication_state: "draft",
|
||||||
|
allow_drafts: true,
|
||||||
|
max_attachments: 0,
|
||||||
|
signature_requirement: "none",
|
||||||
|
policy_refs: [],
|
||||||
|
handoff_kinds: [],
|
||||||
|
pages: [],
|
||||||
|
fallback_locale: null,
|
||||||
|
localizations: [],
|
||||||
|
accessibility: {},
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyField(index: number): FormFieldDefinition {
|
||||||
|
return { key: `field-${index}`, label: "", value_type: "text", required: false, help_text: "", options: [], constraints: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeField(field: FormFieldDefinition): FormFieldDefinition {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
key: field.key.trim(),
|
||||||
|
label: field.label.trim(),
|
||||||
|
help_text: field.help_text?.trim() || null,
|
||||||
|
options: isChoice(field.value_type) ? field.options.map((item) => item.trim()).filter(Boolean) : [],
|
||||||
|
constraints: Object.fromEntries(Object.entries(field.constraints).filter(([, value]) => value !== "" && value !== null && value !== undefined))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField(definition: FormDefinition): FormDefinition {
|
||||||
|
const field = emptyField(definition.fields.length + 1);
|
||||||
|
const pages = definition.pages ?? [];
|
||||||
|
if (pages.length === 0) return { ...definition, fields: [...definition.fields, field] };
|
||||||
|
const firstPage = pages[0];
|
||||||
|
const firstSection = firstPage.sections[0];
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fields: [...definition.fields, field],
|
||||||
|
pages: replaceAt(pages, 0, {
|
||||||
|
...firstPage,
|
||||||
|
sections: replaceAt(firstPage.sections, 0, {
|
||||||
|
...firstSection,
|
||||||
|
field_keys: [...firstSection.field_keys, field.key]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(definition: FormDefinition, index: number): FormDefinition {
|
||||||
|
const key = definition.fields[index].key;
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fields: definition.fields.filter((_, fieldIndex) => fieldIndex !== index),
|
||||||
|
pages: (definition.pages ?? []).map((page) => ({
|
||||||
|
...page,
|
||||||
|
sections: page.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
field_keys: section.field_keys.filter((fieldKey) => fieldKey !== key)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapPageField(pages: FormPageDefinition[], previous: string, next: string): FormPageDefinition[] {
|
||||||
|
return pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
sections: page.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
field_keys: section.field_keys.map((key) => key === previous ? next : key)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPage(fields: FormFieldDefinition[]): FormPageDefinition {
|
||||||
|
return {
|
||||||
|
key: "page-1",
|
||||||
|
title: "Form",
|
||||||
|
sections: [{ key: "section-1", title: "Details", field_keys: fields.map((item) => item.key) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPage(index: number): FormPageDefinition {
|
||||||
|
return { key: `page-${index}`, title: `Page ${index}`, sections: [emptySection(1)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySection(index: number) {
|
||||||
|
return { key: `section-${index}`, title: `Section ${index}`, field_keys: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyLocalization(): FormLocalization {
|
||||||
|
return {
|
||||||
|
locale: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
field_labels: {},
|
||||||
|
field_help_texts: {},
|
||||||
|
option_labels: {},
|
||||||
|
page_titles: {},
|
||||||
|
section_titles: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAt<T>(items: T[], index: number, value: T): T[] {
|
||||||
|
return items.map((item, itemIndex) => itemIndex === index ? value : item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOptionalText<T extends Record<string, unknown>>(current: T, key: string, value: string): T {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value.trim()) next[key as keyof T] = value as T[keyof T];
|
||||||
|
else delete next[key as keyof T];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionInputValue(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConditionValue(value: string, type?: FormValueType): unknown {
|
||||||
|
if (type === "boolean") return value.trim().toLowerCase() === "true";
|
||||||
|
if (type === "integer") return Number.parseInt(value, 10);
|
||||||
|
if (type === "number") return Number(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function definitionChanges(previous: FormDefinition, current: FormDefinition): string[] {
|
||||||
|
const changes: string[] = [];
|
||||||
|
if (previous.title !== current.title) changes.push("Title changed");
|
||||||
|
if (previous.publication_state !== current.publication_state) changes.push(`State: ${previous.publication_state} -> ${current.publication_state}`);
|
||||||
|
if (previous.fields.length !== current.fields.length) changes.push(`Fields: ${previous.fields.length} -> ${current.fields.length}`);
|
||||||
|
if ((previous.pages?.length ?? 0) !== (current.pages?.length ?? 0)) changes.push(`Pages: ${previous.pages?.length ?? 0} -> ${current.pages?.length ?? 0}`);
|
||||||
|
if ((previous.localizations?.length ?? 0) !== (current.localizations?.length ?? 0)) changes.push(`Locales: ${previous.localizations?.length ?? 0} -> ${current.localizations?.length ?? 0}`);
|
||||||
|
if (changes.length === 0 && JSON.stringify(previous) !== JSON.stringify(current)) changes.push("Definition details changed");
|
||||||
|
return changes.length ? changes : ["No unsaved changes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitValues(value: string): string[] {
|
||||||
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChoice(value: string): boolean {
|
||||||
|
return value === "choice" || value === "multi_choice";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchConstraint(current: Record<string, unknown>, key: string, value: string): Record<string, unknown> {
|
||||||
|
const next = { ...current };
|
||||||
|
if (!value.trim()) delete next[key];
|
||||||
|
else next[key] = Number(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTextConstraint(current: Record<string, unknown>, key: string, value: string): Record<string, unknown> {
|
||||||
|
const next = { ...current };
|
||||||
|
if (!value) delete next[key];
|
||||||
|
else next[key] = value;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constraintValue(value: unknown): number | "" {
|
||||||
|
return typeof value === "number" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(definition: FormDefinition, routeAnchor?: string): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"forms",
|
||||||
|
"form_definition",
|
||||||
|
definition.reference.object_id,
|
||||||
|
routeAnchor
|
||||||
|
].filter(Boolean).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticFormDocumentation(definition: FormDefinition): DocumentationHelpReference {
|
||||||
|
return {
|
||||||
|
contextId: semanticHelpContext(definition),
|
||||||
|
documentationType: "user"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticFieldDocumentation(
|
||||||
|
definition: FormDefinition,
|
||||||
|
fieldKey: string
|
||||||
|
): DocumentationHelpReference {
|
||||||
|
return {
|
||||||
|
contextId: semanticHelpContext(definition, `field-${fieldKey}`),
|
||||||
|
documentationType: "user"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(
|
||||||
|
definition: FormDefinition,
|
||||||
|
routeAnchor?: string
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "forms",
|
||||||
|
subjectKind: "form_definition",
|
||||||
|
subjectId: definition.reference.object_id
|
||||||
|
});
|
||||||
|
if (routeAnchor) params.set("routeAnchor", routeAnchor);
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { Download, Pencil, Plus, Search, Upload } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
IconButton,
|
||||||
|
FormField,
|
||||||
|
FilterBar,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
hasScope,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
assessFormDefinitionPackage,
|
||||||
|
exportFormDefinitionPackage,
|
||||||
|
importFormDefinitionPackage,
|
||||||
|
listFormDefinitions,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormPackageFragment
|
||||||
|
} from "../../api/forms";
|
||||||
|
import FormDefinitionDialog from "./FormDefinitionDialog";
|
||||||
|
import { FORMS_DOCUMENTATION, FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
|
const [state, setState] = useState("");
|
||||||
|
const [items, setItems] = useState<FormDefinition[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [importing, setImporting] = useState<FormPackageFragment | null>(null);
|
||||||
|
const [importReason, setImportReason] = useState("");
|
||||||
|
const [importAssessment, setImportAssessment] = useState("");
|
||||||
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const importInput = useRef<HTMLInputElement>(null);
|
||||||
|
const canWrite = hasScope(auth, "forms:definition:write");
|
||||||
|
const canAdmin = hasScope(auth, "forms:definition:admin");
|
||||||
|
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||||
|
|
||||||
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
return listFormDefinitions(settings, {
|
||||||
|
query: submittedQuery,
|
||||||
|
states: state ? [state] : undefined,
|
||||||
|
limit: 200
|
||||||
|
}, signal).
|
||||||
|
then((result) => {
|
||||||
|
setItems(result.definitions);
|
||||||
|
setTotal(result.total);
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
}, [settings, state, submittedQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Form definitions could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const formId = searchParams.get("formId");
|
||||||
|
if (!formId || editing || loading) return;
|
||||||
|
const requested = items.find((item) => item.reference.object_id === formId);
|
||||||
|
if (!requested) return;
|
||||||
|
setEditing(requested);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("formId");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}, [editing, items, loading, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
function search(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePackage(file?: File) {
|
||||||
|
if (!file) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const fragment = JSON.parse(await file.text()) as FormPackageFragment;
|
||||||
|
const assessment = await assessFormDefinitionPackage(settings, fragment);
|
||||||
|
setImporting(fragment);
|
||||||
|
setImportAssessment(assessment.outcome);
|
||||||
|
setImportReason("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be read.");
|
||||||
|
} finally {
|
||||||
|
if (importInput.current) importInput.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPackage(): Promise<boolean> {
|
||||||
|
if (!importing || !importReason.trim()) return false;
|
||||||
|
setImportBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
|
||||||
|
setImporting(null);
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: Boolean(importing && importReason),
|
||||||
|
onSave: importPackage,
|
||||||
|
onDiscard: () => {
|
||||||
|
setImporting(null);
|
||||||
|
setImportReason("");
|
||||||
|
},
|
||||||
|
title: "i18n:govoplan-forms.unsaved_title",
|
||||||
|
message: "i18n:govoplan-forms.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeImport() {
|
||||||
|
if (importBusy) return;
|
||||||
|
if (importing && importReason) requestDiscard(() => setImporting(null));
|
||||||
|
else setImporting(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPackage(item: FormDefinition) {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const fragment = await exportFormDefinitionPackage(settings, item.reference.object_id, item.reference.version);
|
||||||
|
const href = URL.createObjectURL(new Blob([JSON.stringify(fragment, null, 2)], { type: "application/json" }));
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = `${item.key}-${item.reference.version}.govoplan-form.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be exported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="forms-page">
|
||||||
|
<WorkspaceFrame className="forms-shell" label="Form definitions" interfaceId="forms.catalogue" helpContextId="forms.page.catalogue" helpModuleId="forms">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void load(), loading, label: "Refresh definitions" }}
|
||||||
|
className="forms-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="default" onSubmit={search} className="forms-search">
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" />
|
||||||
|
<Button type="submit">Search</Button>
|
||||||
|
</FilterBar>
|
||||||
|
<label>
|
||||||
|
<span>State</span>
|
||||||
|
<select value={state} onChange={(event) => setState(event.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="retired">Retired</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} />
|
||||||
|
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
|
||||||
|
<span className="forms-count">{total}</span>
|
||||||
|
</>}
|
||||||
|
createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
|
||||||
|
helpAction={<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />}
|
||||||
|
/>
|
||||||
|
<PageScrollViewport className="forms-list-viewport">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />}
|
||||||
|
{loading && <LoadingIndicator label="Loading Form definitions" />}
|
||||||
|
{!loading && !error && items.length === 0 && <StatePanel size="compact" description="No matching definitions." />}
|
||||||
|
{!loading && items.length > 0 &&
|
||||||
|
<div className="forms-list" role="list">
|
||||||
|
{items.map((item) => {
|
||||||
|
const mayRevise = canWrite && (item.publication_state === "draft" || canAdmin) && item.publication_state !== "retired";
|
||||||
|
return (
|
||||||
|
<div className="forms-row" role="listitem" key={item.reference.object_id}>
|
||||||
|
<span><strong>{item.title}</strong><small>{item.key}</small></span>
|
||||||
|
<span>{item.fields.length} fields</span>
|
||||||
|
<span>Revision {item.reference.version}</span>
|
||||||
|
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
|
||||||
|
<span className="forms-row-actions">
|
||||||
|
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
|
||||||
|
<IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} disabled={!mayRevise} disabledReason={!canWrite ? FORMS_I18N.writeReason : item.publication_state === "retired" || (item.publication_state === "published" && !canAdmin) ? FORMS_I18N.lifecycleReason : undefined} onClick={() => setEditing(item)} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
{editing &&
|
||||||
|
<FormDefinitionDialog
|
||||||
|
open
|
||||||
|
settings={settings}
|
||||||
|
tenantId={tenantId}
|
||||||
|
definition={editing === "new" ? null : editing}
|
||||||
|
canPublish={canAdmin}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditing(null);
|
||||||
|
void load().catch((reason) => setError(reason instanceof Error ? reason.message : "Definitions could not be reloaded."));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(importing)}
|
||||||
|
title="Import Form package"
|
||||||
|
onClose={closeImport}
|
||||||
|
closeDisabled={importBusy}
|
||||||
|
portal
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={closeImport} disabled={importBusy} disabledReason={importBusy ? FORMS_I18N.busy : undefined}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()} disabledReason={importBusy ? FORMS_I18N.busy : !importReason.trim() ? FORMS_I18N.incomplete : undefined}>{importBusy ? "Importing" : "Import as draft"}</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="forms-package-import">
|
||||||
|
<p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p>
|
||||||
|
<FormField label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const FORMS_DOCUMENTATION = {
|
||||||
|
topicId: "forms.definitions",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FORMS_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "forms.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FORMS_I18N = {
|
||||||
|
loading: "i18n:govoplan-forms.loading_reason",
|
||||||
|
busy: "i18n:govoplan-forms.busy_reason",
|
||||||
|
writeReason: "i18n:govoplan-forms.write_permission_reason",
|
||||||
|
adminReason: "i18n:govoplan-forms.admin_permission_reason",
|
||||||
|
lifecycleReason: "i18n:govoplan-forms.lifecycle_reason",
|
||||||
|
incomplete: "i18n:govoplan-forms.incomplete_reason",
|
||||||
|
oneField: "i18n:govoplan-forms.one_field_reason",
|
||||||
|
requiredAction: "i18n:govoplan-forms.required_action",
|
||||||
|
actor: "i18n:govoplan-forms.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-forms.destination",
|
||||||
|
permissionAction: "i18n:govoplan-forms.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-forms.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-forms.permission_destination"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-forms.form_definitions": "Form definitions",
|
||||||
|
"i18n:govoplan-forms.loading_reason": "Form definitions are still loading.",
|
||||||
|
"i18n:govoplan-forms.busy_reason": "Another Form definition action is still running.",
|
||||||
|
"i18n:govoplan-forms.write_permission_reason": "Your account may not create, import, or revise Form definitions.",
|
||||||
|
"i18n:govoplan-forms.admin_permission_reason": "Publishing and retirement require Form administration permission.",
|
||||||
|
"i18n:govoplan-forms.lifecycle_reason": "Retired definitions cannot be revised; published definitions require administrative authority.",
|
||||||
|
"i18n:govoplan-forms.incomplete_reason": "Complete the title, key, fields, and change reason first.",
|
||||||
|
"i18n:govoplan-forms.one_field_reason": "A Form definition must retain at least one field.",
|
||||||
|
"i18n:govoplan-forms.required_action": "Required action",
|
||||||
|
"i18n:govoplan-forms.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-forms.destination": "Destination",
|
||||||
|
"i18n:govoplan-forms.permission_action": "Ask for the corresponding Form definition permission.",
|
||||||
|
"i18n:govoplan-forms.permission_actor": "An Access or tenant administrator",
|
||||||
|
"i18n:govoplan-forms.permission_destination": "Access role assignments",
|
||||||
|
"i18n:govoplan-forms.unsaved_title": "Unsaved Form definition",
|
||||||
|
"i18n:govoplan-forms.unsaved_message": "Save or discard the Form definition draft before leaving this surface.",
|
||||||
|
"i18n:govoplan-forms.lifecycle_title": "Confirm definition lifecycle",
|
||||||
|
"i18n:govoplan-forms.lifecycle_message": "Save this revision as {state}? Existing submissions keep their exact definition revision.",
|
||||||
|
"Form definitions": "Form definitions",
|
||||||
|
"Search definitions": "Search definitions",
|
||||||
|
"Search Form definitions": "Search Form definitions",
|
||||||
|
"Search": "Search",
|
||||||
|
"State": "State",
|
||||||
|
"All": "All",
|
||||||
|
"Draft": "Draft",
|
||||||
|
"Published": "Published",
|
||||||
|
"Retired": "Retired",
|
||||||
|
"Refresh definitions": "Refresh definitions",
|
||||||
|
"Import Form package": "Import Form package",
|
||||||
|
"New definition": "New definition",
|
||||||
|
"Loading Form definitions": "Loading Form definitions",
|
||||||
|
"No matching definitions.": "No matching definitions.",
|
||||||
|
"Assessment": "Assessment",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Importing": "Importing",
|
||||||
|
"Import as draft": "Import as draft",
|
||||||
|
"Change reason": "Change reason",
|
||||||
|
"New Form definition": "New Form definition",
|
||||||
|
"Saving": "Saving",
|
||||||
|
"Save revision": "Save revision",
|
||||||
|
"Title": "Title",
|
||||||
|
"Key": "Key",
|
||||||
|
"Description": "Description",
|
||||||
|
"Publication state": "Publication state",
|
||||||
|
"Signature": "Signature",
|
||||||
|
"Not used": "Not used",
|
||||||
|
"Optional": "Optional",
|
||||||
|
"Required": "Required",
|
||||||
|
"Maximum attachments": "Maximum attachments",
|
||||||
|
"Draft saving": "Draft saving",
|
||||||
|
"Policy references": "Policy references",
|
||||||
|
"Permitted handoffs": "Permitted handoffs",
|
||||||
|
"Case": "Case",
|
||||||
|
"Workflow": "Workflow",
|
||||||
|
"Record": "Record",
|
||||||
|
"Accessibility instructions": "Accessibility instructions",
|
||||||
|
"Fields": "Fields",
|
||||||
|
"Add field": "Add field",
|
||||||
|
"Label": "Label",
|
||||||
|
"Type": "Type",
|
||||||
|
"Help text": "Help text",
|
||||||
|
"Options": "Options",
|
||||||
|
"Pages and sections": "Pages and sections",
|
||||||
|
"Enable pages": "Enable pages",
|
||||||
|
"Add page": "Add page",
|
||||||
|
"Add section": "Add section",
|
||||||
|
"Localizations": "Localizations",
|
||||||
|
"Add locale": "Add locale",
|
||||||
|
"Preview and revision changes": "Preview and revision changes",
|
||||||
|
"No Form management permission": "No Form management permission"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-forms.form_definitions": "Formulardefinitionen",
|
||||||
|
"i18n:govoplan-forms.loading_reason": "Formulardefinitionen werden noch geladen.",
|
||||||
|
"i18n:govoplan-forms.busy_reason": "Eine andere Aktion für Formulardefinitionen läuft noch.",
|
||||||
|
"i18n:govoplan-forms.write_permission_reason": "Ihr Konto darf Formulardefinitionen nicht erstellen, importieren oder überarbeiten.",
|
||||||
|
"i18n:govoplan-forms.admin_permission_reason": "Veröffentlichung und Stilllegung erfordern die Formularadministration.",
|
||||||
|
"i18n:govoplan-forms.lifecycle_reason": "Stillgelegte Definitionen können nicht überarbeitet werden; veröffentlichte Definitionen erfordern administrative Berechtigung.",
|
||||||
|
"i18n:govoplan-forms.incomplete_reason": "Füllen Sie zuerst Titel, Schlüssel, Felder und Änderungsgrund aus.",
|
||||||
|
"i18n:govoplan-forms.one_field_reason": "Eine Formulardefinition muss mindestens ein Feld behalten.",
|
||||||
|
"i18n:govoplan-forms.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-forms.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-forms.destination": "Ziel",
|
||||||
|
"i18n:govoplan-forms.permission_action": "Fordern Sie die entsprechende Berechtigung für Formulardefinitionen an.",
|
||||||
|
"i18n:govoplan-forms.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
|
||||||
|
"i18n:govoplan-forms.permission_destination": "Zugriff und Rollenzuweisungen",
|
||||||
|
"i18n:govoplan-forms.unsaved_title": "Ungespeicherte Formulardefinition",
|
||||||
|
"i18n:govoplan-forms.unsaved_message": "Speichern oder verwerfen Sie den Entwurf der Formulardefinition, bevor Sie diese Oberfläche verlassen.",
|
||||||
|
"i18n:govoplan-forms.lifecycle_title": "Definitionslebenszyklus bestätigen",
|
||||||
|
"i18n:govoplan-forms.lifecycle_message": "Diese Revision als {state} speichern? Bestehende Einreichungen behalten ihre exakte Definitionsrevision.",
|
||||||
|
"Form definitions": "Formulardefinitionen",
|
||||||
|
"Search definitions": "Definitionen suchen",
|
||||||
|
"Search Form definitions": "Formulardefinitionen durchsuchen",
|
||||||
|
"Search": "Suchen",
|
||||||
|
"State": "Status",
|
||||||
|
"All": "Alle",
|
||||||
|
"Draft": "Entwurf",
|
||||||
|
"Published": "Veröffentlicht",
|
||||||
|
"Retired": "Stillgelegt",
|
||||||
|
"Refresh definitions": "Definitionen aktualisieren",
|
||||||
|
"Import Form package": "Formularpaket importieren",
|
||||||
|
"New definition": "Neue Definition",
|
||||||
|
"Loading Form definitions": "Formulardefinitionen werden geladen",
|
||||||
|
"No matching definitions.": "Keine passenden Definitionen.",
|
||||||
|
"Assessment": "Bewertung",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Importing": "Importieren",
|
||||||
|
"Import as draft": "Als Entwurf importieren",
|
||||||
|
"Change reason": "Änderungsgrund",
|
||||||
|
"New Form definition": "Neue Formulardefinition",
|
||||||
|
"Saving": "Speichern",
|
||||||
|
"Save revision": "Revision speichern",
|
||||||
|
"Title": "Titel",
|
||||||
|
"Key": "Schlüssel",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Publication state": "Veröffentlichungsstatus",
|
||||||
|
"Signature": "Signatur",
|
||||||
|
"Not used": "Nicht verwendet",
|
||||||
|
"Optional": "Optional",
|
||||||
|
"Required": "Erforderlich",
|
||||||
|
"Maximum attachments": "Maximale Anhänge",
|
||||||
|
"Draft saving": "Entwurfsspeicherung",
|
||||||
|
"Policy references": "Richtlinienreferenzen",
|
||||||
|
"Permitted handoffs": "Zulässige Übergaben",
|
||||||
|
"Case": "Fall",
|
||||||
|
"Workflow": "Workflow",
|
||||||
|
"Record": "Datensatz",
|
||||||
|
"Accessibility instructions": "Hinweise zur Barrierefreiheit",
|
||||||
|
"Fields": "Felder",
|
||||||
|
"Add field": "Feld hinzufügen",
|
||||||
|
"Label": "Bezeichnung",
|
||||||
|
"Type": "Typ",
|
||||||
|
"Help text": "Hilfetext",
|
||||||
|
"Options": "Optionen",
|
||||||
|
"Pages and sections": "Seiten und Abschnitte",
|
||||||
|
"Enable pages": "Seiten aktivieren",
|
||||||
|
"Add page": "Seite hinzufügen",
|
||||||
|
"Add section": "Abschnitt hinzufügen",
|
||||||
|
"Localizations": "Lokalisierungen",
|
||||||
|
"Add locale": "Sprache hinzufügen",
|
||||||
|
"Preview and revision changes": "Vorschau und Revisionsänderungen",
|
||||||
|
"No Form management permission": "Keine Berechtigung zur Formularverwaltung"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, formsModule } from "./module";
|
||||||
|
export * from "./api/forms";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/forms.css";
|
||||||
|
|
||||||
|
|
||||||
|
const FormsPage = lazy(() => import("./features/forms/FormsPage"));
|
||||||
|
|
||||||
|
export const formsModule: PlatformWebModule = {
|
||||||
|
id: "forms",
|
||||||
|
label: "i18n:govoplan-forms.form_definitions",
|
||||||
|
version: "0.1.21",
|
||||||
|
dependencies: ["access"],
|
||||||
|
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy", "docs"],
|
||||||
|
translations: generatedTranslations,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/forms",
|
||||||
|
anyOf: ["forms:definition:read"],
|
||||||
|
order: 36,
|
||||||
|
surfaceId: "forms.catalogue",
|
||||||
|
render: (context) => createElement(FormsPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/forms",
|
||||||
|
label: "i18n:govoplan-forms.form_definitions",
|
||||||
|
iconName: "list-tree",
|
||||||
|
anyOf: ["forms:definition:read"],
|
||||||
|
order: 36,
|
||||||
|
surfaceId: "forms.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "forms.navigation", moduleId: "forms", kind: "navigation", label: "Form definitions navigation", order: 10 },
|
||||||
|
{ id: "forms.catalogue", moduleId: "forms", kind: "route", label: "Form definition catalogue", order: 20 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default formsModule;
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
.forms-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-search {
|
||||||
|
flex: 1 1 520px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-toolbar > label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-toolbar > label > span,
|
||||||
|
.forms-count {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-list-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-list-viewport > .action-blocker-hint {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-list {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row > span:first-child strong,
|
||||||
|
.forms-row > span:first-child small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row > span:not(:first-child, .status-badge),
|
||||||
|
.forms-row small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-package-import {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-package-import p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-dialog {
|
||||||
|
width: min(1120px, calc(100vw - 32px));
|
||||||
|
height: min(860px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-editor {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-help {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-handoffs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 18px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-handoffs > span {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px minmax(120px, 0.8fr) minmax(160px, 1.1fr) minmax(130px, 0.8fr) minmax(100px, auto) 36px;
|
||||||
|
align-items: end;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-order {
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-order .icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-required {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-help,
|
||||||
|
.form-field-options,
|
||||||
|
.form-field-constraints,
|
||||||
|
.form-field-condition {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-composition-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-heading h3 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-page-editor,
|
||||||
|
.form-localization-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-page-editor-heading,
|
||||||
|
.form-section-editor,
|
||||||
|
.form-localization-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-editor {
|
||||||
|
grid-template-columns: minmax(120px, 0.7fr) minmax(160px, 1fr) minmax(220px, 1.5fr) auto;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-editor select[multiple] {
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-editor {
|
||||||
|
grid-template-columns: minmax(100px, 0.5fr) minmax(200px, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid ul {
|
||||||
|
margin: 7px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-page small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.forms-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-row > span:nth-child(2),
|
||||||
|
.forms-row > span:nth-child(3) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-row {
|
||||||
|
grid-template-columns: 36px minmax(0, 1fr) 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-row > .form-field {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-order {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-required,
|
||||||
|
.form-field-help,
|
||||||
|
.form-field-options,
|
||||||
|
.form-field-constraints,
|
||||||
|
.form-field-condition {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-row > .icon-button:last-child {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.form-page-editor-heading,
|
||||||
|
.form-section-editor,
|
||||||
|
.form-localization-editor,
|
||||||
|
.form-localization-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-definition-wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user