Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3ebd2d3d | ||
|
|
619af7ecb0 | ||
|
|
e6ca2fbd8b | ||
|
|
3ba0e5ce40 | ||
|
|
8bdce810f2 | ||
|
|
d0678cef9a | ||
|
|
fe007fbfe1 | ||
|
|
07dd35bcc0 | ||
|
|
b4388b1e1e | ||
|
|
9dc49fe27b | ||
|
|
396d6b0c90 | ||
|
|
0046284b95 |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Forms Runtime Codex Guide
|
# GovOPlaN Forms Runtime Codex Guide
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Forms Runtime internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN Forms Runtime platform module seed.
|
This repository owns the GovOPlaN Forms Runtime platform module seed.
|
||||||
|
|||||||
@@ -4,11 +4,22 @@
|
|||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-forms-runtime` is the GovOPlaN platform module seed for runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.
|
`govoplan-forms-runtime` owns definition-aware runtime form submissions,
|
||||||
|
including validation, permitted drafts, attachment/signature references, status
|
||||||
|
history, receipts, and handoff evidence.
|
||||||
|
|
||||||
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
|
Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
|
||||||
|
|
||||||
## Initial Ownership
|
The module persists tenant-bound immutable revisions and events, exposes bounded
|
||||||
|
owner/manager APIs and WebUI routes, and provides both
|
||||||
|
`forms_runtime.registry` and `forms_runtime.service_launcher`.
|
||||||
|
|
||||||
|
Portal invokes the launcher only after re-fetching and re-evaluating an exact
|
||||||
|
published Service revision. A Form binding uses `<form-id>/<revision>`; the
|
||||||
|
runtime resolves that exact immutable definition through `forms.definitions`,
|
||||||
|
validates launch values, and retains both Service and binding provenance.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
- form submissions
|
- form submissions
|
||||||
- draft state
|
- draft state
|
||||||
@@ -17,6 +28,12 @@ This repository is initialized as a discoverable module seed. It exposes a modul
|
|||||||
- signature state
|
- signature state
|
||||||
- handoff status
|
- handoff status
|
||||||
|
|
||||||
|
Submitted instances can create a native Case or start a Workflow through the
|
||||||
|
owning module capability. Runtime persists the exact handoff intent before the
|
||||||
|
external action, commits that intent, uses a stable provider idempotency key,
|
||||||
|
and stores `succeeded`, `rejected`, or `outcome_unknown` evidence. Unknown
|
||||||
|
outcomes are reconciled against the owner rather than retried as a new action.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
This module does not own:
|
This module does not own:
|
||||||
@@ -29,13 +46,20 @@ Detailed boundary notes are in [docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md](docs/FORM
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Expected optional integrations:
|
Required integrations:
|
||||||
|
|
||||||
|
- access
|
||||||
- forms
|
- forms
|
||||||
|
|
||||||
|
Optional integrations:
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- approvals
|
- approvals
|
||||||
- workflow
|
- workflow engine
|
||||||
- portal
|
- portal
|
||||||
|
- cases
|
||||||
|
- policy
|
||||||
|
- audit
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
@@ -46,11 +70,12 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
|
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-forms-runtime
|
cd /mnt/DATA/git/govoplan-forms-runtime
|
||||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
PYTHONPATH=src:/mnt/DATA/git/govoplan-forms/src:/mnt/DATA/git/govoplan-core/src \
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gitea Workflow
|
## Gitea Workflow
|
||||||
|
|||||||
@@ -19,27 +19,102 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
|
|||||||
- document storage
|
- document storage
|
||||||
- domain-specific adjudication
|
- domain-specific adjudication
|
||||||
|
|
||||||
## Integration Candidates
|
## Required Integrations
|
||||||
|
|
||||||
|
- access
|
||||||
- forms
|
- forms
|
||||||
|
|
||||||
|
## Optional Integration Candidates
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- approvals
|
- approvals
|
||||||
- workflow
|
- workflow engine
|
||||||
- portal
|
- portal
|
||||||
|
- cases
|
||||||
|
- policy
|
||||||
|
- audit
|
||||||
|
|
||||||
## Seed State
|
## Implemented State
|
||||||
|
|
||||||
The current repository state is intentionally small:
|
- exact immutable Form-definition resolution through `forms.definitions`
|
||||||
|
- tenant-bound instance identities and append-only revisions/status events
|
||||||
|
- server-side type, option, constraint, required, attachment, signature, and
|
||||||
|
optional policy validation
|
||||||
|
- `started` launch sessions and definition-controlled draft persistence
|
||||||
|
- final submission receipts, handoff references, replay safety, and OCC
|
||||||
|
- actor-bound idempotency that permits an exact retry after definition
|
||||||
|
supersession without exposing another participant's submission
|
||||||
|
- owner-restricted participant access plus manager scopes
|
||||||
|
- bounded list/detail/history/event APIs and accessible definition-driven WebUI
|
||||||
|
- `forms_runtime.service_launcher` retaining exact Service and binding
|
||||||
|
provenance
|
||||||
|
- native Case and Workflow handoffs that commit a durable effect intent before
|
||||||
|
invoking the owner capability, use stable provider idempotency keys, and
|
||||||
|
reconcile outcome-unknown execution
|
||||||
|
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
|
||||||
|
tenant/replay/stale-write/validation/handoff tests
|
||||||
|
|
||||||
- module manifest and entry point
|
## Security And Policy
|
||||||
- tenant-level permission definitions
|
|
||||||
- manager and viewer role templates
|
|
||||||
- documentation topic describing the module boundary
|
|
||||||
- Gitea issue workflow templates
|
|
||||||
- manifest contract test
|
|
||||||
|
|
||||||
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
|
Authenticated accounts receive only the participant role by default. It permits
|
||||||
|
access to their own instances; tenant-wide reads and review/handoff transitions
|
||||||
|
require manager scopes. Event payloads exclude submitted values. Exact
|
||||||
|
definition lookup, publication state, tenant, current authorization, and
|
||||||
|
optional policy references are re-evaluated for each consequential operation.
|
||||||
|
Definition providers must return the requested owner, tenant, object, and exact
|
||||||
|
revision; a mismatched provider response fails closed.
|
||||||
|
Policy-referenced definitions fail closed when no compatible
|
||||||
|
`forms_runtime.policy_evaluator` is active.
|
||||||
|
|
||||||
## First Implementation Slice
|
Files and signature providers retain their own content and key custody. Runtime
|
||||||
|
stores only same-tenant evidence references. Cases and Workflow Engine retain
|
||||||
|
their own target state; Runtime stores only a permitted same-tenant handoff
|
||||||
|
reference and status evidence.
|
||||||
|
|
||||||
Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.
|
## Approved Intake And Evidence Profiles
|
||||||
|
|
||||||
|
The product and security profile approved on 2026-08-04 sets the next
|
||||||
|
implementation boundary:
|
||||||
|
|
||||||
|
- authenticated-account and invitation-token intake are the first public entry
|
||||||
|
profiles;
|
||||||
|
- anonymous intake is available only through an explicit per-form policy
|
||||||
|
opt-in, while a pseudonymous profile remains deferred;
|
||||||
|
- an anonymous submission cannot later be claimed by an identity; an invitation
|
||||||
|
submission can be linked only with explicit consent and proof of that
|
||||||
|
invitation;
|
||||||
|
- invitation tokens are hashed, tenant/form bound, replay safe, rate limited,
|
||||||
|
and expire after 14 days by default;
|
||||||
|
- drafts expire after 30 days by default, while every service/form must declare
|
||||||
|
submitted-data retention explicitly; and
|
||||||
|
- CAPTCHA remains an optional privacy-approved provider instead of a mandatory
|
||||||
|
external dependency.
|
||||||
|
|
||||||
|
Files is the first attachment provider and retains byte storage, quarantine,
|
||||||
|
scanning, classification, retention, and legal-hold ownership. Runtime stores
|
||||||
|
only immutable same-tenant evidence references and must fail closed when a
|
||||||
|
required item is pending, rejected, expired, unavailable, or unverifiable.
|
||||||
|
|
||||||
|
The first native signature profile is an authenticated acknowledgement. It is
|
||||||
|
not an advanced or qualified electronic signature. Those assurance levels
|
||||||
|
require a separately selected external trust-service provider and current
|
||||||
|
provider evidence; a required signature never silently degrades.
|
||||||
|
|
||||||
|
## Recovery And Operations
|
||||||
|
|
||||||
|
Database recovery restores identities, revisions, and events together. After
|
||||||
|
restore, verify one current revision per instance, monotonically increasing
|
||||||
|
revision history, matching event revisions, resolvable exact Form and Service
|
||||||
|
references, and referenced evidence availability. A replay with the original
|
||||||
|
idempotency key and request hash must return the original revision; a changed
|
||||||
|
request must conflict. Failed handoffs leave the prior revision current.
|
||||||
|
|
||||||
|
Destructive retirement is blocked while state exists and requires a verified
|
||||||
|
database snapshot plus an export or retention decision for referenced evidence.
|
||||||
|
No local generated files are required, so API and worker nodes remain stateless.
|
||||||
|
|
||||||
|
The approved public-intake and concrete file/signature provider profiles remain
|
||||||
|
implementation depth. Conditional multi-page definitions are resolved from Forms, and
|
||||||
|
native Case/Workflow handoffs execute automatically when the exact owner
|
||||||
|
capability is installed. Additional target kinds remain adapter depth; the
|
||||||
|
owner and security boundaries no longer depend on those additions.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Forms Runtime Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to the Forms
|
||||||
|
Runtime list, instance, submission, and native handoff surfaces. Core owns the
|
||||||
|
shared controls and interaction states. Forms Runtime keeps ownership of
|
||||||
|
instance revisions, validation, receipts, and handoff evidence.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/forms-runtime` | Filtered work queue | Select assigned or authorized instance | Shared loading/error/empty/help states and locale-aware dates |
|
||||||
|
| `/forms-runtime/:instanceId` | Guided runtime form | Change draft values or inspect immutable submission | Definition-provided fields/help, permission/lifecycle blocker, guarded draft |
|
||||||
|
| Save and submit actions | Consequential editor | Save revision or issue immutable receipt | Explained disabled reasons; submission confirmation; server validation remains authoritative |
|
||||||
|
| Case/Workflow handoffs | External-effect recovery queue | Record intent, execute, retry, reconcile, compensate | Permission reason, start confirmation, explicit unknown-outcome and compensation semantics |
|
||||||
|
| History and revisions | Evidence/provenance | Reconstruct status and value revisions | Immutable chronological evidence with platform-locale timestamps |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Runtime localization follows the platform-selected language, not the browser
|
||||||
|
language independently of the active account preference.
|
||||||
|
- A draft save requires changed values and a reason. Submission is separately
|
||||||
|
confirmed and creates an immutable receipt after server validation.
|
||||||
|
- Read-only fields explain whether permission or lifecycle caused the state.
|
||||||
|
- Handoff intent is persisted before provider execution. Unknown outcomes are
|
||||||
|
reconciled before retry; compensation records verified absence only.
|
||||||
|
- Optional Files, Policy, Case, Workflow, Approval, Portal, and Audit behavior
|
||||||
|
remains behind declared capabilities and interfaces.
|
||||||
|
|
||||||
|
Native controls preserve keyboard order, shared dialogs manage focus, changed
|
||||||
|
values use the global unsaved-draft guard, and bounded list/detail regions keep
|
||||||
|
their existing responsive scrolling. English and German catalogues cover
|
||||||
|
module-owned copy; definition content uses its own published localization.
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/forms-runtime",
|
"name": "@govoplan/forms-runtime",
|
||||||
"version": "0.1.8",
|
"version": "0.1.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Forms Runtime platform module seed.",
|
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-forms-runtime"
|
name = "govoplan-forms-runtime"
|
||||||
version = "0.1.8"
|
version = "0.1.17"
|
||||||
description = "GovOPlaN Forms Runtime platform module seed."
|
description = "Definition-aware form submissions and service launch for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.17",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.17",
|
||||||
|
"govoplan-forms>=0.1.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -22,4 +23,4 @@ where = ["src"]
|
|||||||
govoplan_forms_runtime = ["py.typed"]
|
govoplan_forms_runtime = ["py.typed"]
|
||||||
|
|
||||||
[project.entry-points."govoplan.modules"]
|
[project.entry-points."govoplan.modules"]
|
||||||
"forms-runtime" = "govoplan_forms_runtime.backend.manifest:get_manifest"
|
forms_runtime = "govoplan_forms_runtime.backend.manifest:get_manifest"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime database models."""
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
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 FormInstanceIdentity(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_instance_identities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
name="uq_form_instance_identity",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_instance_owner",
|
||||||
|
"tenant_id",
|
||||||
|
"created_by",
|
||||||
|
"definition_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
definition_revision: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_instance_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_form_instance_revision",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_instance_current",
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"superseded_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_instance_catalog",
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"recorded_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
identity_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("form_instance_identities.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("form_instance_revisions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
changed_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceEvent(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_instance_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"event_id",
|
||||||
|
name="uq_form_instance_event",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_instance_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_instance_event_history",
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"occurred_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FormHandoffEffect(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_handoff_effects"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"effect_id",
|
||||||
|
name="uq_form_handoff_effect",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_handoff_idempotency",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_key",
|
||||||
|
name="uq_form_handoff_provider_key",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_handoff_instance_state",
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"state",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
effect_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
provider_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
binding_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
binding_reference: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
provider_capability: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
requested_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
target_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
href: Mapped[str | None] = mapped_column(String(2000), nullable=True)
|
||||||
|
evidence: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(String(2000), nullable=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormInstanceEvent",
|
||||||
|
"FormHandoffEffect",
|
||||||
|
"FormInstanceIdentity",
|
||||||
|
"FormInstanceRevision",
|
||||||
|
]
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Mapping
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FORM_INSTANCE_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"started",
|
||||||
|
"draft",
|
||||||
|
"submitted",
|
||||||
|
"validated",
|
||||||
|
"needs_review",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"handed_off",
|
||||||
|
"archived",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
FormHandoffState = Literal[
|
||||||
|
"requested",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
"reconciled",
|
||||||
|
"compensated",
|
||||||
|
]
|
||||||
|
FORM_HANDOFF_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"requested",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
"reconciled",
|
||||||
|
"compensated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormHandoff:
|
||||||
|
tenant_id: str
|
||||||
|
instance_id: str
|
||||||
|
effect_id: str
|
||||||
|
instance_revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
provider_key: str
|
||||||
|
request_sha256: str
|
||||||
|
binding_kind: str
|
||||||
|
binding_reference: str
|
||||||
|
provider_capability: str
|
||||||
|
state: FormHandoffState
|
||||||
|
attempt_count: int
|
||||||
|
requested_at: datetime
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
target_ref: InstitutionalReference | None = None
|
||||||
|
href: str | None = None
|
||||||
|
evidence: tuple[EvidenceReference, ...] = ()
|
||||||
|
last_error: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.state not in FORM_HANDOFF_STATES:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
f"Unsupported Form handoff state: {self.state!r}."
|
||||||
|
)
|
||||||
|
if self.attempt_count < 0:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form handoff attempt count cannot be negative."
|
||||||
|
)
|
||||||
|
if self.target_ref is not None and self.target_ref.tenant_id != self.tenant_id:
|
||||||
|
raise InstitutionalContextError("Form handoff target cannot cross tenants.")
|
||||||
|
if any(item.tenant_id != self.tenant_id for item in self.evidence):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form handoff evidence cannot cross tenants."
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"effect_id": self.effect_id,
|
||||||
|
"instance_id": self.instance_id,
|
||||||
|
"instance_revision": self.instance_revision,
|
||||||
|
"binding_kind": self.binding_kind,
|
||||||
|
"binding_reference": self.binding_reference,
|
||||||
|
"provider_capability": self.provider_capability,
|
||||||
|
"state": self.state,
|
||||||
|
"attempt_count": self.attempt_count,
|
||||||
|
"requested_at": self.requested_at.isoformat(),
|
||||||
|
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||||
|
"target_ref": self.target_ref.to_dict() if self.target_ref else None,
|
||||||
|
"href": self.href,
|
||||||
|
"evidence": [
|
||||||
|
item.to_dict(include_inspection=False) for item in self.evidence
|
||||||
|
],
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormInstance:
|
||||||
|
tenant_id: str
|
||||||
|
instance_id: str
|
||||||
|
revision: int
|
||||||
|
status: str
|
||||||
|
definition_ref: InstitutionalReference
|
||||||
|
values: Mapping[str, object]
|
||||||
|
validation_results: tuple[Mapping[str, object], ...]
|
||||||
|
recorded_at: datetime
|
||||||
|
change_reason: str
|
||||||
|
created_by: str
|
||||||
|
changed_by: str
|
||||||
|
attachment_refs: tuple[EvidenceReference, ...] = ()
|
||||||
|
signature_refs: tuple[EvidenceReference, ...] = ()
|
||||||
|
handoff_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
service_ref: InstitutionalReference | None = None
|
||||||
|
service_binding: ServiceBinding | None = None
|
||||||
|
receipt_id: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id or not self.instance_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"A Form instance requires tenant and instance identities."
|
||||||
|
)
|
||||||
|
if self.revision < 1:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"A Form instance revision must be positive."
|
||||||
|
)
|
||||||
|
if self.status not in FORM_INSTANCE_STATUSES:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
f"Unsupported Form instance status: {self.status!r}."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.definition_ref.kind != "form"
|
||||||
|
or self.definition_ref.owner_module != "forms"
|
||||||
|
or self.definition_ref.tenant_id != self.tenant_id
|
||||||
|
or not self.definition_ref.version
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"A Form instance requires an exact same-tenant Forms definition."
|
||||||
|
)
|
||||||
|
if self.service_ref is not None and (
|
||||||
|
self.service_ref.kind != "service"
|
||||||
|
or self.service_ref.tenant_id != self.tenant_id
|
||||||
|
or not self.service_ref.version
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form instance Service provenance must be exact and same-tenant."
|
||||||
|
)
|
||||||
|
for item in (*self.attachment_refs, *self.signature_refs):
|
||||||
|
if item.tenant_id != self.tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form instance evidence cannot cross tenants."
|
||||||
|
)
|
||||||
|
for item in self.handoff_refs:
|
||||||
|
if item.tenant_id != self.tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Form instance handoff references cannot cross tenants."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference(self) -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind="form_submission",
|
||||||
|
owner_module="forms_runtime",
|
||||||
|
object_id=self.instance_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
version=str(self.revision),
|
||||||
|
valid_at=self.recorded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def with_replay(self) -> "FormInstance":
|
||||||
|
return replace(self, replayed=True)
|
||||||
|
|
||||||
|
def to_dict(self, *, include_values: bool = True) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"reference": self.reference.to_dict(),
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"instance_id": self.instance_id,
|
||||||
|
"revision": self.revision,
|
||||||
|
"status": self.status,
|
||||||
|
"definition_ref": self.definition_ref.to_dict(disclose_label=True),
|
||||||
|
"values": dict(self.values) if include_values else {},
|
||||||
|
"validation_results": [dict(item) for item in self.validation_results],
|
||||||
|
"attachment_refs": [
|
||||||
|
item.to_dict(include_inspection=False) for item in self.attachment_refs
|
||||||
|
],
|
||||||
|
"signature_refs": [
|
||||||
|
item.to_dict(include_inspection=False) for item in self.signature_refs
|
||||||
|
],
|
||||||
|
"handoff_refs": [item.to_dict() for item in self.handoff_refs],
|
||||||
|
"service_ref": (
|
||||||
|
self.service_ref.to_dict() if self.service_ref is not None else None
|
||||||
|
),
|
||||||
|
"service_binding": (
|
||||||
|
self.service_binding.to_dict()
|
||||||
|
if self.service_binding is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"receipt_id": self.receipt_id,
|
||||||
|
"recorded_at": self.recorded_at.isoformat(),
|
||||||
|
"change_reason": self.change_reason,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"changed_by": self.changed_by,
|
||||||
|
"replayed": self.replayed,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FORM_HANDOFF_STATES",
|
||||||
|
"FORM_INSTANCE_STATUSES",
|
||||||
|
"FormHandoff",
|
||||||
|
"FormHandoffState",
|
||||||
|
"FormInstance",
|
||||||
|
]
|
||||||
@@ -0,0 +1,782 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceDefinitionProvider,
|
||||||
|
ServiceLaunchRequest,
|
||||||
|
ServiceLaunchResult,
|
||||||
|
ServiceLauncher,
|
||||||
|
service_launch_capability,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormHandoffEffect,
|
||||||
|
FormInstanceEvent,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormHandoff, FormHandoffState
|
||||||
|
from govoplan_forms_runtime.backend.service import (
|
||||||
|
FormRuntimeError,
|
||||||
|
FormRuntimeService,
|
||||||
|
_aware,
|
||||||
|
_capability,
|
||||||
|
_current_instance,
|
||||||
|
_principal_actor,
|
||||||
|
_principal_tenant,
|
||||||
|
_request_hash,
|
||||||
|
_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HANDOFF_NAMESPACE = uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL,
|
||||||
|
"https://govoplan.add-ideas.de/contracts/forms-runtime/handoff/v1",
|
||||||
|
)
|
||||||
|
_NATIVE_HANDOFF_KINDS = frozenset({"case", "workflow"})
|
||||||
|
_TERMINAL_STATES = frozenset({"accepted", "reconciled", "compensated"})
|
||||||
|
|
||||||
|
|
||||||
|
class FormHandoffService:
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
self._runtime = FormRuntimeService(registry)
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
binding_kind: str,
|
||||||
|
binding_reference: str | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
requested_at: datetime,
|
||||||
|
allow_all: bool,
|
||||||
|
) -> FormHandoff:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
actor_id = _principal_actor(principal)
|
||||||
|
clean_kind = _text(binding_kind, "Form handoff kind", 30)
|
||||||
|
if clean_kind not in _NATIVE_HANDOFF_KINDS:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Native Form handoffs currently support Case and Workflow targets."
|
||||||
|
)
|
||||||
|
clean_key = _text(idempotency_key, "Form handoff idempotency key", 255)
|
||||||
|
if requested_at.tzinfo is None or requested_at.utcoffset() is None:
|
||||||
|
raise FormRuntimeError("Form handoff requested_at must include a timezone.")
|
||||||
|
replay = (
|
||||||
|
session.query(FormHandoffEffect)
|
||||||
|
.filter(
|
||||||
|
FormHandoffEffect.tenant_id == tenant_id,
|
||||||
|
FormHandoffEffect.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
if (
|
||||||
|
replay.instance_id != instance_id
|
||||||
|
or replay.instance_revision != expected_revision
|
||||||
|
or replay.binding_kind != clean_kind
|
||||||
|
or _aware(replay.requested_at) != requested_at
|
||||||
|
or (
|
||||||
|
binding_reference is not None
|
||||||
|
and replay.binding_reference != binding_reference
|
||||||
|
)
|
||||||
|
or str(replay.details.get("requested_by") or "") != actor_id
|
||||||
|
):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Form handoff idempotency conflict: this key was used for another request."
|
||||||
|
)
|
||||||
|
return _handoff_from_row(replay)
|
||||||
|
current, _ = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if current.revision != expected_revision:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Form instance revision conflict: the expected revision is stale."
|
||||||
|
)
|
||||||
|
if current.status not in {"submitted", "validated", "needs_review", "accepted"}:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
f"Form status {current.status!r} does not permit a native handoff."
|
||||||
|
)
|
||||||
|
definition = self._runtime._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=current.definition_ref,
|
||||||
|
effective_at=requested_at,
|
||||||
|
)
|
||||||
|
if clean_kind not in definition.handoff_kinds:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
f"Form definition does not permit a {clean_kind!r} handoff."
|
||||||
|
)
|
||||||
|
service, binding, provider_capability = self._resolve_target(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
current=current,
|
||||||
|
binding_kind=clean_kind,
|
||||||
|
binding_reference=binding_reference,
|
||||||
|
effective_at=requested_at,
|
||||||
|
)
|
||||||
|
self._runtime._evaluate_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
action=f"handoff:{clean_kind}:request",
|
||||||
|
instance=current,
|
||||||
|
)
|
||||||
|
self._launcher(provider_capability)
|
||||||
|
request = {
|
||||||
|
"instance_id": current.instance_id,
|
||||||
|
"instance_revision": current.revision,
|
||||||
|
"definition_ref": current.definition_ref.to_dict(),
|
||||||
|
"service_ref": service.reference.to_dict(),
|
||||||
|
"binding": binding.to_dict(),
|
||||||
|
"actor_id": actor_id,
|
||||||
|
"requested_at": requested_at.isoformat(),
|
||||||
|
}
|
||||||
|
request_sha256 = _request_hash(request)
|
||||||
|
effect_id = str(uuid.uuid4())
|
||||||
|
provider_key = str(
|
||||||
|
uuid.uuid5(
|
||||||
|
_HANDOFF_NAMESPACE,
|
||||||
|
":".join((tenant_id, current.instance_id, clean_key)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = FormHandoffEffect(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance_id=current.instance_id,
|
||||||
|
effect_id=effect_id,
|
||||||
|
instance_revision=current.revision,
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
provider_key=provider_key,
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
binding_kind=clean_kind,
|
||||||
|
binding_reference=binding.reference,
|
||||||
|
provider_capability=provider_capability,
|
||||||
|
state="requested",
|
||||||
|
attempt_count=0,
|
||||||
|
requested_at=requested_at,
|
||||||
|
evidence=[],
|
||||||
|
details={
|
||||||
|
"requested_by": actor_id,
|
||||||
|
"definition_ref": current.definition_ref.to_dict(),
|
||||||
|
"service_ref": service.reference.to_dict(),
|
||||||
|
"binding": binding.to_dict(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="requested",
|
||||||
|
occurred_at=requested_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
effect_id: str,
|
||||||
|
executed_at: datetime,
|
||||||
|
allow_all: bool,
|
||||||
|
reconcile: bool = False,
|
||||||
|
) -> tuple[FormHandoff, object | None]:
|
||||||
|
row = self._effect_row(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if row.state in _TERMINAL_STATES:
|
||||||
|
return _handoff_from_row(row), None
|
||||||
|
if reconcile:
|
||||||
|
if row.state != "outcome_unknown":
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Only an outcome-unknown Form handoff can be reconciled."
|
||||||
|
)
|
||||||
|
elif row.state != "requested":
|
||||||
|
if row.state == "outcome_unknown":
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Outcome-unknown Form handoffs must be reconciled with the same provider key."
|
||||||
|
)
|
||||||
|
raise FormRuntimeError(
|
||||||
|
f"Form handoff state {row.state!r} cannot be executed."
|
||||||
|
)
|
||||||
|
current, _ = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if current.revision != row.instance_revision:
|
||||||
|
return self._fail_before_effect(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="rejected",
|
||||||
|
occurred_at=executed_at,
|
||||||
|
message="The Form instance changed after this handoff was requested.",
|
||||||
|
), None
|
||||||
|
definition = self._runtime._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=current.definition_ref,
|
||||||
|
effective_at=executed_at,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._runtime._evaluate_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
action=f"handoff:{row.binding_kind}:confirm",
|
||||||
|
instance=current,
|
||||||
|
)
|
||||||
|
service, binding, provider_capability = self._resolve_target(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
current=current,
|
||||||
|
binding_kind=row.binding_kind,
|
||||||
|
binding_reference=row.binding_reference,
|
||||||
|
effective_at=executed_at,
|
||||||
|
)
|
||||||
|
if provider_capability != row.provider_capability:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"The configured Form handoff provider changed after request."
|
||||||
|
)
|
||||||
|
launcher = self._launcher(row.provider_capability)
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
PermissionError,
|
||||||
|
LookupError,
|
||||||
|
) as exc:
|
||||||
|
return self._fail_before_effect(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="rejected",
|
||||||
|
occurred_at=executed_at,
|
||||||
|
message=str(exc),
|
||||||
|
), None
|
||||||
|
|
||||||
|
row.attempt_count += 1
|
||||||
|
launch_request = ServiceLaunchRequest(
|
||||||
|
service_ref=service.reference,
|
||||||
|
binding=binding,
|
||||||
|
idempotency_key=row.provider_key,
|
||||||
|
requested_at=_aware(row.requested_at),
|
||||||
|
parameters=_handoff_parameters(current),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
result = launcher.launch_service(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=service,
|
||||||
|
request=launch_request,
|
||||||
|
)
|
||||||
|
_validate_result(
|
||||||
|
result,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
service=service,
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
InstitutionalContextError,
|
||||||
|
FormRuntimeError,
|
||||||
|
PermissionError,
|
||||||
|
LookupError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
row.state = "rejected"
|
||||||
|
row.last_error = _bounded_error(exc)
|
||||||
|
row.resolved_at = executed_at
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="rejected",
|
||||||
|
occurred_at=executed_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row), None
|
||||||
|
except Exception as exc:
|
||||||
|
# The provider may have accepted the effect before connectivity was
|
||||||
|
# lost. Retain the exact provider key and require reconciliation.
|
||||||
|
row.state = "outcome_unknown"
|
||||||
|
row.last_error = _bounded_error(exc)
|
||||||
|
row.resolved_at = executed_at
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="outcome_unknown",
|
||||||
|
occurred_at=executed_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row), None
|
||||||
|
|
||||||
|
target_refs = current.handoff_refs
|
||||||
|
if result.target_ref is not None:
|
||||||
|
target_refs = tuple(dict.fromkeys((*target_refs, result.target_ref)))
|
||||||
|
final_state: FormHandoffState = "reconciled" if reconcile else "accepted"
|
||||||
|
revised = self._runtime._revise(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
current=current,
|
||||||
|
identity=_current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)[1],
|
||||||
|
expected_revision=current.revision,
|
||||||
|
status="handed_off",
|
||||||
|
values=current.values,
|
||||||
|
attachment_refs=current.attachment_refs,
|
||||||
|
signature_refs=current.signature_refs,
|
||||||
|
handoff_refs=target_refs,
|
||||||
|
idempotency_key=f"handoff:{row.effect_id}:{final_state}",
|
||||||
|
recorded_at=executed_at,
|
||||||
|
change_reason=(
|
||||||
|
f"Reconciled {row.binding_kind} handoff."
|
||||||
|
if reconcile
|
||||||
|
else f"Completed {row.binding_kind} handoff."
|
||||||
|
),
|
||||||
|
operation=f"handoff_{final_state}",
|
||||||
|
final_validation=True,
|
||||||
|
definition=definition,
|
||||||
|
allowed_current_statuses=(
|
||||||
|
"submitted",
|
||||||
|
"validated",
|
||||||
|
"needs_review",
|
||||||
|
"accepted",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row.state = final_state
|
||||||
|
row.resolved_at = executed_at
|
||||||
|
row.target_ref = result.target_ref.to_dict() if result.target_ref else None
|
||||||
|
row.href = result.href
|
||||||
|
row.evidence = [
|
||||||
|
item.to_dict(include_inspection=False) for item in result.evidence
|
||||||
|
]
|
||||||
|
row.last_error = None
|
||||||
|
row.details = {
|
||||||
|
**dict(row.details),
|
||||||
|
"provider_result": dict(result.metadata),
|
||||||
|
"provider_replayed": result.replayed,
|
||||||
|
}
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=revised.status,
|
||||||
|
state=final_state,
|
||||||
|
occurred_at=executed_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row), revised
|
||||||
|
|
||||||
|
def retry_rejected(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
effect_id: str,
|
||||||
|
requested_at: datetime,
|
||||||
|
allow_all: bool,
|
||||||
|
) -> FormHandoff:
|
||||||
|
row = self._effect_row(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if row.state != "rejected":
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Only a rejected Form handoff can be retried. Outcome-unknown effects require reconciliation."
|
||||||
|
)
|
||||||
|
current, _ = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if current.revision != row.instance_revision:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"The Form instance changed after this handoff failed; create a new handoff request."
|
||||||
|
)
|
||||||
|
row.state = "requested"
|
||||||
|
row.last_error = None
|
||||||
|
row.resolved_at = None
|
||||||
|
row.requested_at = requested_at
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="requested",
|
||||||
|
occurred_at=requested_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row)
|
||||||
|
|
||||||
|
def compensate(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
effect_id: str,
|
||||||
|
compensated_at: datetime,
|
||||||
|
confirmed_absent: bool,
|
||||||
|
change_reason: str,
|
||||||
|
allow_all: bool,
|
||||||
|
) -> FormHandoff:
|
||||||
|
if not confirmed_absent:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Compensation requires confirmation that no target effect exists."
|
||||||
|
)
|
||||||
|
row = self._effect_row(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if row.state not in {"rejected", "outcome_unknown"}:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Only rejected or outcome-unknown Form handoffs can be compensated."
|
||||||
|
)
|
||||||
|
current, _ = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
definition = self._runtime._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=current.definition_ref,
|
||||||
|
effective_at=compensated_at,
|
||||||
|
)
|
||||||
|
self._runtime._evaluate_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
action=f"handoff:{row.binding_kind}:compensate",
|
||||||
|
instance=current,
|
||||||
|
)
|
||||||
|
row.state = "compensated"
|
||||||
|
row.resolved_at = compensated_at
|
||||||
|
row.last_error = None
|
||||||
|
row.details = {
|
||||||
|
**dict(row.details),
|
||||||
|
"compensation": {
|
||||||
|
"confirmed_absent": True,
|
||||||
|
"change_reason": _text(change_reason, "Compensation reason", 1000),
|
||||||
|
"actor_id": _principal_actor(principal),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=current.status,
|
||||||
|
state="compensated",
|
||||||
|
occurred_at=compensated_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row)
|
||||||
|
|
||||||
|
def list(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance_id: str,
|
||||||
|
allow_all: bool,
|
||||||
|
) -> tuple[FormHandoff, ...]:
|
||||||
|
self._runtime.get_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
session.query(FormHandoffEffect)
|
||||||
|
.filter(
|
||||||
|
FormHandoffEffect.tenant_id == _principal_tenant(principal),
|
||||||
|
FormHandoffEffect.instance_id == instance_id,
|
||||||
|
)
|
||||||
|
.order_by(FormHandoffEffect.requested_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return tuple(_handoff_from_row(row) for row in rows)
|
||||||
|
|
||||||
|
def _resolve_target(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
current: object,
|
||||||
|
binding_kind: str,
|
||||||
|
binding_reference: str | None,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> tuple[ServiceDefinition, ServiceBinding, str]:
|
||||||
|
service_ref = getattr(current, "service_ref", None)
|
||||||
|
if not isinstance(service_ref, InstitutionalReference):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"A native Form handoff requires an exact Service launch provenance."
|
||||||
|
)
|
||||||
|
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
|
||||||
|
if not isinstance(provider, ServiceDefinitionProvider):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"The configured Service definition provider is unavailable or invalid."
|
||||||
|
)
|
||||||
|
service = provider.get_service_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=service_ref,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
if service is None or service.reference != service_ref:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"The exact Service definition for this Form handoff is unavailable."
|
||||||
|
)
|
||||||
|
candidates = tuple(
|
||||||
|
item
|
||||||
|
for item in service.bindings
|
||||||
|
if item.kind == binding_kind
|
||||||
|
and (binding_reference is None or item.reference == binding_reference)
|
||||||
|
)
|
||||||
|
if len(candidates) != 1:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Select exactly one matching Service handoff binding."
|
||||||
|
)
|
||||||
|
return service, candidates[0], service_launch_capability(binding_kind)
|
||||||
|
|
||||||
|
def _launcher(self, capability: str) -> ServiceLauncher:
|
||||||
|
launcher = _capability(self._registry, capability)
|
||||||
|
if not isinstance(launcher, ServiceLauncher):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
f"The configured handoff provider is unavailable or invalid: {capability}."
|
||||||
|
)
|
||||||
|
return launcher
|
||||||
|
|
||||||
|
def _effect_row(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
effect_id: str,
|
||||||
|
lock: bool,
|
||||||
|
allow_all: bool,
|
||||||
|
) -> FormHandoffEffect:
|
||||||
|
query = session.query(FormHandoffEffect).filter(
|
||||||
|
FormHandoffEffect.tenant_id == _principal_tenant(principal),
|
||||||
|
FormHandoffEffect.effect_id == effect_id,
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
row = query.one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise LookupError("Form handoff not found.")
|
||||||
|
instance = self._runtime.get_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if instance is None:
|
||||||
|
raise LookupError("Form instance not found.")
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _fail_before_effect(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
row: FormHandoffEffect,
|
||||||
|
instance_status: str,
|
||||||
|
state: FormHandoffState,
|
||||||
|
occurred_at: datetime,
|
||||||
|
message: str,
|
||||||
|
) -> FormHandoff:
|
||||||
|
row.state = state
|
||||||
|
row.last_error = message[:2000]
|
||||||
|
row.resolved_at = occurred_at
|
||||||
|
_record_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row=row,
|
||||||
|
instance_status=instance_status,
|
||||||
|
state=state,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
)
|
||||||
|
return _handoff_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_parameters(instance: object) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"title": f"Form submission {getattr(instance, 'instance_id')}",
|
||||||
|
"form_submission_id": getattr(instance, "instance_id"),
|
||||||
|
"form_submission_revision": getattr(instance, "revision"),
|
||||||
|
"form_definition_ref": getattr(instance, "definition_ref").to_dict(),
|
||||||
|
"form_values": dict(getattr(instance, "values")),
|
||||||
|
"attachment_refs": [
|
||||||
|
item.to_dict(include_inspection=False)
|
||||||
|
for item in getattr(instance, "attachment_refs")
|
||||||
|
],
|
||||||
|
"signature_refs": [
|
||||||
|
item.to_dict(include_inspection=False)
|
||||||
|
for item in getattr(instance, "signature_refs")
|
||||||
|
],
|
||||||
|
"actor_id": getattr(instance, "changed_by"),
|
||||||
|
"institutional_context": dict(getattr(instance, "metadata")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_result(
|
||||||
|
result: ServiceLaunchResult,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service: ServiceDefinition,
|
||||||
|
binding: ServiceBinding,
|
||||||
|
) -> None:
|
||||||
|
if result.service_ref != service.reference or result.binding != binding:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"The handoff provider returned evidence for another Service binding."
|
||||||
|
)
|
||||||
|
if result.target_ref is not None and result.target_ref.tenant_id != tenant_id:
|
||||||
|
raise FormRuntimeError("The handoff provider returned a cross-tenant target.")
|
||||||
|
if any(item.tenant_id != tenant_id for item in result.evidence):
|
||||||
|
raise FormRuntimeError("The handoff provider returned cross-tenant evidence.")
|
||||||
|
|
||||||
|
|
||||||
|
def _record_transition(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
row: FormHandoffEffect,
|
||||||
|
instance_status: str,
|
||||||
|
state: FormHandoffState,
|
||||||
|
occurred_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
event = FormInstanceEvent(
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
instance_revision=row.instance_revision,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type=f"forms_runtime.handoff.{state}",
|
||||||
|
status=instance_status,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
actor_id=_principal_actor(principal),
|
||||||
|
idempotency_key=f"handoff:{row.effect_id}:{state}:{row.attempt_count}",
|
||||||
|
request_sha256=row.request_sha256,
|
||||||
|
payload={
|
||||||
|
"effect_id": row.effect_id,
|
||||||
|
"binding_kind": row.binding_kind,
|
||||||
|
"binding_reference": row.binding_reference,
|
||||||
|
"provider_capability": row.provider_capability,
|
||||||
|
"provider_key_sha256": hashlib.sha256(
|
||||||
|
row.provider_key.encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"state": state,
|
||||||
|
"attempt_count": row.attempt_count,
|
||||||
|
"target_ref": row.target_ref,
|
||||||
|
"href": row.href,
|
||||||
|
"last_error": row.last_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
session.flush()
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
event_id=event_id,
|
||||||
|
type=event.event_type,
|
||||||
|
module_id="forms_runtime",
|
||||||
|
payload=dict(event.payload),
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
||||||
|
tenant=EventTenantRef(id=row.tenant_id),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="form_submission",
|
||||||
|
id=row.instance_id,
|
||||||
|
),
|
||||||
|
classification="confidential",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_from_row(row: FormHandoffEffect) -> FormHandoff:
|
||||||
|
return FormHandoff(
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
instance_id=row.instance_id,
|
||||||
|
effect_id=row.effect_id,
|
||||||
|
instance_revision=row.instance_revision,
|
||||||
|
idempotency_key=row.idempotency_key,
|
||||||
|
provider_key=row.provider_key,
|
||||||
|
request_sha256=row.request_sha256,
|
||||||
|
binding_kind=row.binding_kind,
|
||||||
|
binding_reference=row.binding_reference,
|
||||||
|
provider_capability=row.provider_capability,
|
||||||
|
state=row.state, # type: ignore[arg-type]
|
||||||
|
attempt_count=row.attempt_count,
|
||||||
|
requested_at=_aware(row.requested_at),
|
||||||
|
resolved_at=_aware(row.resolved_at) if row.resolved_at else None,
|
||||||
|
target_ref=(
|
||||||
|
InstitutionalReference.from_mapping(row.target_ref)
|
||||||
|
if isinstance(row.target_ref, Mapping)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
href=row.href,
|
||||||
|
evidence=tuple(_evidence_from_mapping(item) for item in (row.evidence or [])),
|
||||||
|
last_error=row.last_error,
|
||||||
|
metadata=dict(row.details),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_from_mapping(value: Mapping[str, object]):
|
||||||
|
from govoplan_core.core.institutional import EvidenceReference
|
||||||
|
|
||||||
|
return EvidenceReference.from_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_error(exc: Exception) -> str:
|
||||||
|
message = str(exc).strip() or type(exc).__name__
|
||||||
|
return message[:2000]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FormHandoffService"]
|
||||||
@@ -1,20 +1,62 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
|
||||||
MODULE_ID = "forms-runtime"
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_forms_runtime.backend.db import models as runtime_models
|
||||||
|
from govoplan_forms_runtime.backend.service import (
|
||||||
|
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
||||||
|
CAPABILITY_FORMS_RUNTIME_REGISTRY,
|
||||||
|
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER,
|
||||||
|
FormRuntimeService,
|
||||||
|
FormsServiceLauncher,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "forms_runtime"
|
||||||
MODULE_NAME = "Forms Runtime"
|
MODULE_NAME = "Forms Runtime"
|
||||||
MODULE_VERSION = "0.1.8"
|
MODULE_VERSION = "0.1.17"
|
||||||
READ_SCOPE = "forms-runtime:workspace:read"
|
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
||||||
WRITE_SCOPE = "forms-runtime:workspace:write"
|
READ_SCOPE = "forms_runtime:workspace:read"
|
||||||
ADMIN_SCOPE = "forms-runtime:workspace:admin"
|
WRITE_SCOPE = "forms_runtime:workspace:write"
|
||||||
|
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"forms",
|
|
||||||
"files",
|
"files",
|
||||||
"approvals",
|
"approvals",
|
||||||
"workflow",
|
"workflow_engine",
|
||||||
"portal",
|
"portal",
|
||||||
|
"cases",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +66,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
scope=scope,
|
scope=scope,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
category="Forms Runtime",
|
category=MODULE_NAME,
|
||||||
level="tenant",
|
level="tenant",
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
resource=resource,
|
resource=resource,
|
||||||
@@ -33,66 +75,340 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View forms runtime workspace", "Read forms runtime records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage forms runtime workspace", "Create and update forms runtime records and workflow state."),
|
PARTICIPATE_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer forms runtime workspace", "Configure forms runtime policies, templates, and tenant-level administration."),
|
"Complete assigned forms",
|
||||||
|
"Start, read, save, and submit the acting account's own Form instances.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
READ_SCOPE,
|
||||||
|
"View form submissions",
|
||||||
|
"Read tenant Form instances, immutable history, and status evidence.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage form submissions",
|
||||||
|
"Review, transition, and hand off tenant Form submissions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer Forms Runtime",
|
||||||
|
"Administer Forms Runtime policy, recovery, and retirement.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="forms_runtime_participant",
|
||||||
|
name="Forms participant",
|
||||||
|
description="Complete the acting account's own Forms.",
|
||||||
|
permissions=(PARTICIPATE_SCOPE,),
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="forms_runtime_manager",
|
slug="forms_runtime_manager",
|
||||||
name="Forms Runtime manager",
|
name="Forms Runtime manager",
|
||||||
description="Manage forms runtime records and workflow state.",
|
description="Review, transition, and hand off Form submissions.",
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="forms_runtime_viewer",
|
slug="forms_runtime_viewer",
|
||||||
name="Forms Runtime viewer",
|
name="Forms Runtime viewer",
|
||||||
description="Read forms runtime records and workflow context.",
|
description="Read Form submissions and their immutable history.",
|
||||||
permissions=(READ_SCOPE,),
|
permissions=(READ_SCOPE,),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
|
||||||
DocumentationTopic(
|
def _router(context: ModuleContext):
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
from govoplan_forms_runtime.backend.router import create_router
|
||||||
title=f"{MODULE_NAME} module boundary",
|
|
||||||
summary="Runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.",
|
return create_router(context.registry)
|
||||||
body=(
|
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
def _registry(context: ModuleContext) -> FormRuntimeService:
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
return FormRuntimeService(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
|
||||||
|
return FormsServiceLauncher(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
current = session.query(runtime_models.FormInstanceRevision).filter(
|
||||||
|
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
|
||||||
|
runtime_models.FormInstanceRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"form_instances": current.count(),
|
||||||
|
"open_form_instances": current.filter(
|
||||||
|
runtime_models.FormInstanceRevision.status.in_(
|
||||||
|
("started", "draft", "submitted", "validated", "needs_review")
|
||||||
|
)
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name=MODULE_NAME,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("access", "forms"),
|
||||||
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
),
|
),
|
||||||
layer="available",
|
optional_capabilities=(
|
||||||
documentation_types=("admin",),
|
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
order=100,
|
"cases.service_launcher",
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
"workflow_engine.service_launcher",
|
||||||
|
),
|
||||||
|
permissions=PERMISSIONS,
|
||||||
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/forms-runtime",
|
||||||
|
label="Forms",
|
||||||
|
icon="form",
|
||||||
|
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/forms-runtime-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/forms-runtime",
|
||||||
|
component="FormsRuntimePage",
|
||||||
|
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
FrontendRoute(
|
||||||
|
path="/forms-runtime/:instanceId",
|
||||||
|
component="FormInstancePage",
|
||||||
|
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||||
|
order=38,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/forms-runtime",
|
||||||
|
label="Forms",
|
||||||
|
icon="form",
|
||||||
|
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="forms_runtime.navigation",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="navigation",
|
||||||
|
label="Forms navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="forms_runtime.workspace",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Forms workspace",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="forms_runtime.instance",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Form instance",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="forms.definitions",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="services.definitions",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="cases.service_launcher",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="workflow_engine.service_launcher",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
|
||||||
|
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
||||||
|
label="Forms Runtime registry",
|
||||||
|
summary="Manages tenant-bound, revisioned Form instances and handoff evidence.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: CapabilityDocumentation(
|
||||||
|
label="Form-bound Service launcher",
|
||||||
|
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
migration_after=("forms",),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
runtime_models.FormHandoffEffect,
|
||||||
|
runtime_models.FormInstanceEvent,
|
||||||
|
runtime_models.FormInstanceRevision,
|
||||||
|
runtime_models.FormInstanceIdentity,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement removes submissions and immutable status evidence and requires a verified database and referenced-evidence recovery plan.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
runtime_models.FormHandoffEffect,
|
||||||
|
runtime_models.FormInstanceIdentity,
|
||||||
|
runtime_models.FormInstanceRevision,
|
||||||
|
runtime_models.FormInstanceEvent,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms_runtime.submissions",
|
||||||
|
title="Complete and manage Forms",
|
||||||
|
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
|
||||||
|
body=(
|
||||||
|
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
|
||||||
|
"Service launches retain the exact Service and binding. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Repository domain boundary",
|
label="Forms Runtime security and recovery",
|
||||||
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"domain_objects": ['form submissions', 'draft state', 'runtime validation results', 'attachment references', 'signature state', 'handoff status'],
|
"help_contexts": [
|
||||||
"first_slice": "Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.",
|
"forms_runtime.navigation",
|
||||||
|
"forms_runtime.workspace",
|
||||||
|
"forms_runtime.instance",
|
||||||
|
"forms_runtime.state.read-only",
|
||||||
|
"forms_runtime.state.permission-blocked",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Form values are returned only through tenant-bound instance permissions and ownership rules.",
|
||||||
|
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
|
||||||
|
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
DocumentationTopic(
|
||||||
|
id="forms_runtime.reference.fields-and-consequences",
|
||||||
manifest = ModuleManifest(
|
title="Form values, submission, and handoff consequences",
|
||||||
id=MODULE_ID,
|
summary="Runtime field behavior, immutable receipts, draft revisions, optional evidence, and recoverable external effects.",
|
||||||
name=MODULE_NAME,
|
body=(
|
||||||
version=MODULE_VERSION,
|
"The active instance resolves one exact published Form definition revision. Visibility conditions alter presentation, "
|
||||||
dependencies=("access",),
|
"not server validation or authorization. Saving a permitted draft creates a new revision with its change reason. "
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
"Submitting validates values, attachments, signatures, and policy requirements and records an immutable receipt; it is "
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
"not an editable draft save. A Case or Workflow handoff records intent before calling its optional provider and uses a "
|
||||||
permissions=PERMISSIONS,
|
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
|
||||||
role_templates=ROLE_TEMPLATES,
|
"duplicate target. Administrative compensation records verified absence and never deletes a remote target."
|
||||||
documentation=DOCUMENTATION,
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Forms Runtime security and recovery",
|
||||||
|
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"forms_runtime.field.dynamic-value",
|
||||||
|
"forms_runtime.field.change-reason",
|
||||||
|
"forms_runtime.field.handoff-kind",
|
||||||
|
"forms_runtime.field.target-binding",
|
||||||
|
"forms_runtime.action.save-draft",
|
||||||
|
"forms_runtime.action.submit",
|
||||||
|
"forms_runtime.action.start-handoff",
|
||||||
|
"forms_runtime.action.reconcile-handoff",
|
||||||
|
"forms_runtime.action.compensate-handoff",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"save_draft": "Creates an immutable draft revision with a change reason.",
|
||||||
|
"submit": "Validates the exact definition and creates an immutable submission receipt.",
|
||||||
|
"start_handoff": "Persists intent before invoking an optional Case or Workflow provider.",
|
||||||
|
"reconcile": "Resolves an outcome-unknown effect without unsafe duplicate execution.",
|
||||||
|
"compensate": "Records an administrative proof that no target effect exists.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="human_work_procedure",
|
||||||
|
kind="runtime",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||||
|
test_ref="tests/test_forms_runtime.py",
|
||||||
|
known_limits=(
|
||||||
|
"Anonymous public intake, concrete Files/signature adapters, and target kinds beyond the native Case/Workflow handoffs remain adapter depth; authenticated Portal entry is supported.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=("native_authoritative",),
|
||||||
|
owned_concepts=(
|
||||||
|
"form instance",
|
||||||
|
"form submission",
|
||||||
|
"runtime validation",
|
||||||
|
"submission receipt",
|
||||||
|
"form handoff evidence",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"form definition",
|
||||||
|
"file content",
|
||||||
|
"case",
|
||||||
|
"workflow definition",
|
||||||
|
"signature key custody",
|
||||||
|
),
|
||||||
|
reference_packages=("product.service-to-decision",),
|
||||||
|
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||||
|
recovery_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||||
|
security_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||||
|
operations_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime Alembic revisions."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime migration versions."""
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
"""Persist governed Forms Runtime handoff effects.
|
||||||
|
|
||||||
|
Revision ID: a3d5f7b9c1e2
|
||||||
|
Revises: f2a3b4c5d6e7
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a3d5f7b9c1e2"
|
||||||
|
down_revision = "f2a3b4c5d6e7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_handoff_effects",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("effect_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("provider_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("binding_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("binding_reference", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("provider_capability", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("target_ref", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("href", sa.String(length=2000), nullable=True),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("last_error", sa.String(length=2000), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_handoff_effects")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "effect_id", name="uq_form_handoff_effect"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "idempotency_key", name="uq_form_handoff_idempotency"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "provider_key", name="uq_form_handoff_provider_key"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"effect_id",
|
||||||
|
"binding_kind",
|
||||||
|
"state",
|
||||||
|
"requested_at",
|
||||||
|
"resolved_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_handoff_effects_{column}"),
|
||||||
|
"form_handoff_effects",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_handoff_instance_state",
|
||||||
|
"form_handoff_effects",
|
||||||
|
["tenant_id", "instance_id", "state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_handoff_effects")
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
"""v0.1.14 definition-aware Forms Runtime.
|
||||||
|
|
||||||
|
Revision ID: f2a3b4c5d6e7
|
||||||
|
Revises: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f2a3b4c5d6e7"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "e1f2a3b4c5d6"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_instance_identities",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("definition_revision", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_identities")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
name="uq_form_instance_identity",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
"created_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_instance_identities_{column}"),
|
||||||
|
"form_instance_identities",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_instance_owner",
|
||||||
|
"form_instance_identities",
|
||||||
|
["tenant_id", "created_by", "definition_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_instance_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("changed_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["identity_id"],
|
||||||
|
["form_instance_identities.id"],
|
||||||
|
name=op.f("fk_form_instance_revisions_identity_id_form_instance_identities"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["form_instance_revisions.id"],
|
||||||
|
name=op.f("fk_form_instance_revisions_previous_revision_id_form_instance_revisions"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_form_instance_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"identity_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"status",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"changed_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_instance_revisions_{column}"),
|
||||||
|
"form_instance_revisions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_instance_current",
|
||||||
|
"form_instance_revisions",
|
||||||
|
["tenant_id", "instance_id", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_instance_catalog",
|
||||||
|
"form_instance_revisions",
|
||||||
|
["tenant_id", "status", "recorded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_instance_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("instance_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_events")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"event_id",
|
||||||
|
name="uq_form_instance_event",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_instance_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"event_id",
|
||||||
|
"event_type",
|
||||||
|
"status",
|
||||||
|
"occurred_at",
|
||||||
|
"actor_id",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_instance_events_{column}"),
|
||||||
|
"form_instance_events",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_instance_event_history",
|
||||||
|
"form_instance_events",
|
||||||
|
["tenant_id", "instance_id", "occurred_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_instance_events")
|
||||||
|
op.drop_table("form_instance_revisions")
|
||||||
|
op.drop_table("form_instance_identities")
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 (
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_forms_runtime.backend.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
PARTICIPATE_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.schemas import (
|
||||||
|
FormDraftUpdateRequest,
|
||||||
|
FormHandoffRequest,
|
||||||
|
FormHandoffActionRequest,
|
||||||
|
FormHandoffCompensateRequest,
|
||||||
|
FormInstanceCreateRequest,
|
||||||
|
FormInstanceEventsResponse,
|
||||||
|
FormInstanceHistoryResponse,
|
||||||
|
FormInstanceListResponse,
|
||||||
|
FormSubmitRequest,
|
||||||
|
FormTransitionRequest,
|
||||||
|
FormNativeHandoffRequest,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||||
|
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(registry: object | None) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
|
||||||
|
runtime = FormRuntimeService(registry)
|
||||||
|
handoffs = FormHandoffService(registry)
|
||||||
|
|
||||||
|
@router.get("/instances", response_model=FormInstanceListResponse)
|
||||||
|
def api_list_instances(
|
||||||
|
instance_status: list[str] | None = Query(default=None, alias="status"),
|
||||||
|
definition_id: str | None = Query(default=None, max_length=255),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FormInstanceListResponse:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items, total = runtime.list_instances(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
statuses=instance_status,
|
||||||
|
definition_id=definition_id,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except FormRuntimeError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return FormInstanceListResponse(
|
||||||
|
instances=[item.to_dict(include_values=False) for item in items],
|
||||||
|
total=total,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_instance(
|
||||||
|
payload: FormInstanceCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.create_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=InstitutionalReference.from_mapping(
|
||||||
|
payload.definition_ref
|
||||||
|
),
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
instance_id=payload.instance_id,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}", response_model=dict[str, object])
|
||||||
|
def api_get_instance(
|
||||||
|
instance_id: str,
|
||||||
|
revision: int | None = Query(default=None, ge=1),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.get_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
revision=revision,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/definition",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_get_instance_definition(
|
||||||
|
instance_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.get_instance_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.patch("/instances/{instance_id}", response_model=dict[str, object])
|
||||||
|
def api_update_draft(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormDraftUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.update_draft(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/submit",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_submit_instance(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormSubmitRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.submit_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/transition",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_transition_instance(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormTransitionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.transition_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
status=payload.status,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/handoffs",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_handoff_instance(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormHandoffRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = runtime.handoff_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
target_ref=InstitutionalReference.from_mapping(payload.target_ref),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/handoffs",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_list_handoffs(
|
||||||
|
instance_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items = handoffs.list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except (FormRuntimeError, LookupError, PermissionError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {"handoffs": [item.to_dict() for item in items]}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/handoffs/native",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_native_handoff(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormNativeHandoffRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
prepared = handoffs.prepare(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
binding_kind=payload.binding_kind,
|
||||||
|
binding_reference=payload.binding_reference,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
requested_at=payload.requested_at,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
# The intent is durable before owner capability execution starts.
|
||||||
|
session.commit()
|
||||||
|
item, instance = handoffs.execute(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=prepared.effect_id,
|
||||||
|
executed_at=payload.requested_at,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {
|
||||||
|
"handoff": item.to_dict(),
|
||||||
|
"instance": instance.to_dict() if instance is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/handoffs/{effect_id}/retry",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_retry_handoff(
|
||||||
|
instance_id: str,
|
||||||
|
effect_id: str,
|
||||||
|
payload: FormHandoffActionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
prepared = handoffs.retry_rejected(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
requested_at=payload.recorded_at,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
if prepared.instance_id != instance_id:
|
||||||
|
raise LookupError("Form handoff does not belong to this instance.")
|
||||||
|
session.commit()
|
||||||
|
item, instance = handoffs.execute(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
executed_at=payload.recorded_at,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {
|
||||||
|
"handoff": item.to_dict(),
|
||||||
|
"instance": instance.to_dict() if instance is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/handoffs/{effect_id}/reconcile",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_reconcile_handoff(
|
||||||
|
instance_id: str,
|
||||||
|
effect_id: str,
|
||||||
|
payload: FormHandoffActionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item, instance = handoffs.execute(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
executed_at=payload.recorded_at,
|
||||||
|
allow_all=True,
|
||||||
|
reconcile=True,
|
||||||
|
)
|
||||||
|
if item.instance_id != instance_id:
|
||||||
|
raise LookupError("Form handoff does not belong to this instance.")
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {
|
||||||
|
"handoff": item.to_dict(),
|
||||||
|
"instance": instance.to_dict() if instance is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/handoffs/{effect_id}/compensate",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_compensate_handoff(
|
||||||
|
instance_id: str,
|
||||||
|
effect_id: str,
|
||||||
|
payload: FormHandoffCompensateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = handoffs.compensate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
effect_id=effect_id,
|
||||||
|
compensated_at=payload.recorded_at,
|
||||||
|
confirmed_absent=payload.confirmed_absent,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
if item.instance_id != instance_id:
|
||||||
|
raise LookupError("Form handoff does not belong to this instance.")
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {"handoff": item.to_dict()}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/history",
|
||||||
|
response_model=FormInstanceHistoryResponse,
|
||||||
|
)
|
||||||
|
def api_instance_history(
|
||||||
|
instance_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FormInstanceHistoryResponse:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items = runtime.history(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
limit=limit,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
||||||
|
return FormInstanceHistoryResponse(revisions=[item.to_dict() for item in items])
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/events",
|
||||||
|
response_model=FormInstanceEventsResponse,
|
||||||
|
)
|
||||||
|
def api_instance_events(
|
||||||
|
instance_id: str,
|
||||||
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FormInstanceEventsResponse:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items = runtime.events(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
limit=limit,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE),
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return FormInstanceEventsResponse(events=[dict(item) for item in items])
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence(values: list[dict[str, object]]) -> tuple[EvidenceReference, ...]:
|
||||||
|
return tuple(EvidenceReference.from_mapping(item) for item in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
if not any(has_scope(principal, scope) for scope in scopes):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Missing one of the scopes: {', '.join(scopes)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error(exc: Exception) -> HTTPException:
|
||||||
|
message = str(exc)
|
||||||
|
lowered = message.casefold()
|
||||||
|
if isinstance(exc, LookupError):
|
||||||
|
code = 404
|
||||||
|
elif isinstance(exc, PermissionError):
|
||||||
|
code = 403
|
||||||
|
elif any(word in lowered for word in ("conflict", "stale", "already")):
|
||||||
|
code = 409
|
||||||
|
elif "validation" in lowered:
|
||||||
|
code = 422
|
||||||
|
else:
|
||||||
|
code = 400
|
||||||
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["create_router"]
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
definition_ref: dict[str, Any]
|
||||||
|
values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
|
||||||
|
signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
instance_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FormDraftUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
values: dict[str, Any]
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
|
||||||
|
signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormSubmitRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
values: dict[str, Any]
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
|
||||||
|
signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FormTransitionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
status: Literal[
|
||||||
|
"validated",
|
||||||
|
"needs_review",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormHandoffRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
target_ref: dict[str, Any]
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormNativeHandoffRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
binding_kind: Literal["case", "workflow"]
|
||||||
|
binding_reference: str | None = Field(default=None, min_length=1, max_length=500)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
requested_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FormHandoffActionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FormHandoffCompensateRequest(FormHandoffActionRequest):
|
||||||
|
confirmed_absent: bool
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceListResponse(BaseModel):
|
||||||
|
instances: list[dict[str, Any]]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceHistoryResponse(BaseModel):
|
||||||
|
revisions: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class FormInstanceEventsResponse(BaseModel):
|
||||||
|
events: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormDraftUpdateRequest",
|
||||||
|
"FormHandoffRequest",
|
||||||
|
"FormHandoffActionRequest",
|
||||||
|
"FormHandoffCompensateRequest",
|
||||||
|
"FormNativeHandoffRequest",
|
||||||
|
"FormInstanceCreateRequest",
|
||||||
|
"FormInstanceEventsResponse",
|
||||||
|
"FormInstanceHistoryResponse",
|
||||||
|
"FormInstanceListResponse",
|
||||||
|
"FormSubmitRequest",
|
||||||
|
"FormTransitionRequest",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
|||||||
|
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 (
|
||||||
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
FormConditionExpression,
|
||||||
|
FormDefinition,
|
||||||
|
FormFieldDefinition,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceLaunchRequest,
|
||||||
|
ServiceLaunchResult,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.service import (
|
||||||
|
SqlFormDefinitionProvider,
|
||||||
|
record_form_definition,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormHandoffEffect,
|
||||||
|
FormInstanceEvent,
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.service import (
|
||||||
|
FormRuntimeError,
|
||||||
|
FormRuntimeService,
|
||||||
|
FormsServiceLauncher,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self, provider: object, **capabilities: object) -> None:
|
||||||
|
self.capabilities = {
|
||||||
|
CAPABILITY_FORM_DEFINITIONS: provider,
|
||||||
|
**capabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.capabilities
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
return self.capabilities[name]
|
||||||
|
|
||||||
|
|
||||||
|
def form_definition(
|
||||||
|
*,
|
||||||
|
form_id: str = "permit-form",
|
||||||
|
revision: str = "1",
|
||||||
|
policy_refs: tuple[str, ...] = (),
|
||||||
|
) -> FormDefinition:
|
||||||
|
return FormDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id=form_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=revision,
|
||||||
|
),
|
||||||
|
key=form_id,
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision=revision,
|
||||||
|
recorded_at=NOW + timedelta(minutes=int(revision) - 2),
|
||||||
|
change_reason=("Initial schema." if revision == "1" else "Revise schema."),
|
||||||
|
),
|
||||||
|
title="Permit form",
|
||||||
|
fields=(
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="name",
|
||||||
|
label="Name",
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 2},
|
||||||
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="delivery",
|
||||||
|
label="Delivery",
|
||||||
|
value_type="choice",
|
||||||
|
options=("portal", "mail"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
publication_state="published",
|
||||||
|
allow_drafts=True,
|
||||||
|
handoff_kinds=("case",),
|
||||||
|
policy_refs=policy_refs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
FormDefinitionRevision.__table__,
|
||||||
|
FormInstanceIdentity.__table__,
|
||||||
|
FormInstanceRevision.__table__,
|
||||||
|
FormInstanceEvent.__table__,
|
||||||
|
FormHandoffEffect.__table__,
|
||||||
|
):
|
||||||
|
table.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
self.definition = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=form_definition(),
|
||||||
|
)
|
||||||
|
self.registry = Registry(SqlFormDefinitionProvider())
|
||||||
|
self.runtime = FormRuntimeService(self.registry)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_draft_submit_occ_replay_and_status_history(self) -> None:
|
||||||
|
draft = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={},
|
||||||
|
idempotency_key="create-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual("draft", draft.status)
|
||||||
|
self.assertEqual("warning", draft.validation_results[0]["severity"])
|
||||||
|
|
||||||
|
saved = self.runtime.update_draft(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "Ada", "delivery": "portal"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="save-1",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Complete required values.",
|
||||||
|
)
|
||||||
|
submitted = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
values=saved.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="submit-1",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
replay = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
values=saved.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="submit-1",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("submitted", submitted.status)
|
||||||
|
self.assertIsNotNone(submitted.receipt_id)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(
|
||||||
|
[3, 2, 1],
|
||||||
|
[
|
||||||
|
item.revision
|
||||||
|
for item in self.runtime.history(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "stale"):
|
||||||
|
self.runtime.transition_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
status="validated",
|
||||||
|
idempotency_key="transition-stale",
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
change_reason="Review complete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validation_policy_tenant_and_handoff_fail_closed(self) -> None:
|
||||||
|
draft = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={},
|
||||||
|
idempotency_key="create-2",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "failed validation"):
|
||||||
|
self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "A"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="invalid-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(PermissionError, "denied"):
|
||||||
|
self.runtime.get_instance(
|
||||||
|
self.session,
|
||||||
|
Principal(account_id="account-2"),
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="valid-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "cross tenants"):
|
||||||
|
self.runtime.handoff_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=submitted.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
target_ref=InstitutionalReference(
|
||||||
|
kind="case",
|
||||||
|
owner_module="cases",
|
||||||
|
object_id="case-1",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
),
|
||||||
|
idempotency_key="handoff-invalid",
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
change_reason="Create case.",
|
||||||
|
)
|
||||||
|
current = self.runtime.get_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(2, current.revision if current else None)
|
||||||
|
|
||||||
|
protected = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=form_definition(
|
||||||
|
form_id="protected-form",
|
||||||
|
policy_refs=("policy:protected-intake",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(PermissionError, "policy evaluator"):
|
||||||
|
self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=protected.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="protected-start",
|
||||||
|
recorded_at=NOW + timedelta(minutes=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_service_launcher_retains_exact_service_form_and_replay(self) -> None:
|
||||||
|
binding = ServiceBinding(kind="form", reference="permit-form/1")
|
||||||
|
service = ServiceDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="service",
|
||||||
|
owner_module="services",
|
||||||
|
object_id="permit-service",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="4",
|
||||||
|
),
|
||||||
|
key="permit-service",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
recorded_at=NOW - timedelta(minutes=2),
|
||||||
|
change_reason="Publish form entry.",
|
||||||
|
),
|
||||||
|
title="Apply for permit",
|
||||||
|
audience=("resident",),
|
||||||
|
bindings=(binding,),
|
||||||
|
publication_state="published",
|
||||||
|
)
|
||||||
|
request = ServiceLaunchRequest(
|
||||||
|
service_ref=service.reference,
|
||||||
|
binding=binding,
|
||||||
|
idempotency_key="portal-1",
|
||||||
|
requested_at=NOW,
|
||||||
|
parameters={},
|
||||||
|
)
|
||||||
|
launcher = FormsServiceLauncher(self.registry)
|
||||||
|
first = launcher.launch_service(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=service,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
replay = launcher.launch_service(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=service,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"form_submission", first.target_ref.kind if first.target_ref else None
|
||||||
|
)
|
||||||
|
self.assertEqual("1", first.metadata["form_definition_revision"])
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(first.target_ref.object_id, replay.target_ref.object_id)
|
||||||
|
|
||||||
|
def test_create_replay_is_actor_bound_and_survives_schema_supersession(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
first = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="create-replay",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=form_definition(revision="2"),
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="create-replay",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(first.instance_id, replay.instance_id)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "another actor"):
|
||||||
|
self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
Principal(account_id="account-2"),
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="create-replay",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_rejects_a_provider_returning_another_exact_revision(self) -> None:
|
||||||
|
class MismatchedProvider:
|
||||||
|
def get_form_definition(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
*,
|
||||||
|
reference,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
return form_definition(revision="2")
|
||||||
|
|
||||||
|
def list_form_definitions(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
*,
|
||||||
|
tenant_id,
|
||||||
|
query="",
|
||||||
|
limit=100,
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
runtime = FormRuntimeService(Registry(MismatchedProvider()))
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "different definition"):
|
||||||
|
runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="mismatched-provider",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_client_supplied_instance_id_cannot_replace_existing_state(self) -> None:
|
||||||
|
self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="fixed-instance-first",
|
||||||
|
recorded_at=NOW,
|
||||||
|
instance_id="fixed-instance",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "instance id is already"):
|
||||||
|
self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Grace"},
|
||||||
|
idempotency_key="fixed-instance-second",
|
||||||
|
recorded_at=NOW,
|
||||||
|
instance_id="fixed-instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hidden_required_fields_are_not_required_or_persisted(self) -> None:
|
||||||
|
conditional = replace(
|
||||||
|
form_definition(form_id="conditional-form"),
|
||||||
|
fields=(
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="include_details",
|
||||||
|
label="Include details",
|
||||||
|
value_type="boolean",
|
||||||
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="details",
|
||||||
|
label="Details",
|
||||||
|
required=True,
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="include_details",
|
||||||
|
operator="eq",
|
||||||
|
value=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stored = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=conditional,
|
||||||
|
)
|
||||||
|
hidden = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=stored.reference,
|
||||||
|
values={"include_details": False, "details": "stale secret"},
|
||||||
|
idempotency_key="conditional-hidden",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertNotIn("details", hidden.values)
|
||||||
|
submitted = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=hidden.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=hidden.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="conditional-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", submitted.status)
|
||||||
|
|
||||||
|
shown = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=stored.reference,
|
||||||
|
values={"include_details": True},
|
||||||
|
idempotency_key="conditional-shown",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "required"):
|
||||||
|
self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=shown.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=shown.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="conditional-shown-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_native_handoff_is_durable_replay_safe_and_reconciles_unknown(self) -> None:
|
||||||
|
form_binding = ServiceBinding(kind="form", reference="permit-form/1")
|
||||||
|
case_binding = ServiceBinding(kind="case", reference="permit-case")
|
||||||
|
service = ServiceDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="service",
|
||||||
|
owner_module="services",
|
||||||
|
object_id="permit-service",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="4",
|
||||||
|
),
|
||||||
|
key="permit-service",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
recorded_at=NOW - timedelta(minutes=2),
|
||||||
|
change_reason="Publish intake targets.",
|
||||||
|
),
|
||||||
|
title="Apply for permit",
|
||||||
|
audience=("resident",),
|
||||||
|
bindings=(form_binding, case_binding),
|
||||||
|
publication_state="published",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Services:
|
||||||
|
def get_service_definition(
|
||||||
|
self, session, principal, *, reference, effective_at=None
|
||||||
|
):
|
||||||
|
return service if reference == service.reference else None
|
||||||
|
|
||||||
|
def list_service_definitions(
|
||||||
|
self, session, principal, *, tenant_id, query="", limit=100
|
||||||
|
):
|
||||||
|
return (service,)
|
||||||
|
|
||||||
|
class Cases:
|
||||||
|
fail_unknown = False
|
||||||
|
|
||||||
|
def launch_service(self, session, principal, *, definition, request):
|
||||||
|
if self.fail_unknown:
|
||||||
|
raise ConnectionError("Provider response was lost.")
|
||||||
|
return ServiceLaunchResult(
|
||||||
|
service_ref=definition.reference,
|
||||||
|
binding=request.binding,
|
||||||
|
state="started",
|
||||||
|
target_ref=InstitutionalReference(
|
||||||
|
kind="case",
|
||||||
|
owner_module="cases",
|
||||||
|
object_id="case-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="1",
|
||||||
|
),
|
||||||
|
href="/cases/case-1",
|
||||||
|
replayed=request.idempotency_key.startswith("known"),
|
||||||
|
metadata={"case_number": "CASE-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cases = Cases()
|
||||||
|
registry = Registry(
|
||||||
|
SqlFormDefinitionProvider(),
|
||||||
|
**{
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS: Services(),
|
||||||
|
"cases.service_launcher": cases,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
runtime = FormRuntimeService(registry)
|
||||||
|
draft = runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="native-create",
|
||||||
|
recorded_at=NOW,
|
||||||
|
service_ref=service.reference,
|
||||||
|
service_binding=form_binding,
|
||||||
|
)
|
||||||
|
submitted = runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="native-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
handoffs = FormHandoffService(registry)
|
||||||
|
prepared = handoffs.prepare(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=submitted.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
binding_kind="case",
|
||||||
|
binding_reference="permit-case",
|
||||||
|
idempotency_key="native-handoff",
|
||||||
|
requested_at=NOW + timedelta(minutes=2),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual("requested", prepared.state)
|
||||||
|
self.assertEqual(
|
||||||
|
"requested",
|
||||||
|
self.session.query(FormHandoffEffect).one().state,
|
||||||
|
)
|
||||||
|
accepted, revised = handoffs.execute(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
effect_id=prepared.effect_id,
|
||||||
|
executed_at=NOW + timedelta(minutes=2),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("accepted", accepted.state)
|
||||||
|
self.assertEqual("handed_off", revised.status)
|
||||||
|
replay = handoffs.prepare(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=submitted.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
binding_kind="case",
|
||||||
|
binding_reference="permit-case",
|
||||||
|
idempotency_key="native-handoff",
|
||||||
|
requested_at=NOW + timedelta(minutes=2),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("accepted", replay.state)
|
||||||
|
|
||||||
|
second = runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Grace"},
|
||||||
|
idempotency_key="unknown-create",
|
||||||
|
recorded_at=NOW,
|
||||||
|
service_ref=service.reference,
|
||||||
|
service_binding=form_binding,
|
||||||
|
)
|
||||||
|
second = runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=second.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=second.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="unknown-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
unknown = handoffs.prepare(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=second.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
binding_kind="case",
|
||||||
|
binding_reference=None,
|
||||||
|
idempotency_key="unknown-handoff",
|
||||||
|
requested_at=NOW + timedelta(minutes=2),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
cases.fail_unknown = True
|
||||||
|
unknown, _ = handoffs.execute(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
effect_id=unknown.effect_id,
|
||||||
|
executed_at=NOW + timedelta(minutes=2),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("outcome_unknown", unknown.state)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "must be reconciled"):
|
||||||
|
handoffs.execute(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
effect_id=unknown.effect_id,
|
||||||
|
executed_at=NOW + timedelta(minutes=3),
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
cases.fail_unknown = False
|
||||||
|
reconciled, revised = handoffs.execute(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
effect_id=unknown.effect_id,
|
||||||
|
executed_at=NOW + timedelta(minutes=4),
|
||||||
|
allow_all=True,
|
||||||
|
reconcile=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("reconciled", reconciled.state)
|
||||||
|
self.assertEqual("handed_off", revised.status)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_forms_runtime.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_routes_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual(
|
||||||
|
{"/forms-runtime", "/forms-runtime/:instanceId"},
|
||||||
|
{item.path for item in frontend.routes}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"forms_runtime.navigation", "forms_runtime.workspace", "forms_runtime.instance"},
|
||||||
|
{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_runtime.submissions"]
|
||||||
|
reference = topics["forms_runtime.reference.fields-and-consequences"]
|
||||||
|
self.assertIn("forms_runtime.instance", guide.metadata["help_contexts"])
|
||||||
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
|
self.assertIn("forms_runtime.action.submit", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("submit", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("compensate", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+32
-12
@@ -2,22 +2,42 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_forms_runtime.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
from govoplan_forms_runtime.backend.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
PARTICIPATE_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
get_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManifestSeedTests(unittest.TestCase):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_definition_aware_runtime(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "forms-runtime")
|
self.assertEqual(manifest.id, "forms_runtime")
|
||||||
self.assertEqual(manifest.name, "Forms Runtime")
|
self.assertEqual(manifest.dependencies, ("access", "forms"))
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
{permission.scope for permission in manifest.permissions},
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"forms_runtime_manager", "forms_runtime_viewer"})
|
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||||
self.assertTrue(manifest.documentation)
|
)
|
||||||
self.assertIsNone(manifest.route_factory)
|
participant = next(
|
||||||
self.assertIsNone(manifest.migration_spec)
|
item
|
||||||
self.assertIsNone(manifest.frontend)
|
for item in manifest.role_templates
|
||||||
|
if item.slug == "forms_runtime_participant"
|
||||||
|
)
|
||||||
|
self.assertTrue(participant.default_authenticated)
|
||||||
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertIn(
|
||||||
|
"forms_runtime.service_launcher",
|
||||||
|
manifest.capability_factories,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"@govoplan/forms-runtime-webui",
|
||||||
|
manifest.frontend.package_name if manifest.frontend else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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 as get_forms_manifest
|
||||||
|
from govoplan_forms_runtime.backend.manifest import get_manifest as get_runtime_manifest
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeMigrationTests(unittest.TestCase):
|
||||||
|
def test_fresh_migration_creates_runtime_store_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="govoplan-forms-runtime-migration-"
|
||||||
|
) as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'forms-runtime.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("forms", "forms_runtime"),
|
||||||
|
manifest_factories=(get_forms_manifest, get_runtime_manifest),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"form_definition_revisions",
|
||||||
|
"form_instance_identities",
|
||||||
|
"form_instance_revisions",
|
||||||
|
"form_instance_events",
|
||||||
|
"form_handoff_effects",
|
||||||
|
}.issubset(inspect(engine).get_table_names())
|
||||||
|
)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"a3d5f7b9c1e2",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/forms-runtime-webui",
|
||||||
|
"version": "0.1.17",
|
||||||
|
"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-runtime.css": "./src/styles/forms-runtime.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.17",
|
||||||
|
"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,272 @@
|
|||||||
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type InstitutionalReference = {
|
||||||
|
kind: string;
|
||||||
|
owner_module: string;
|
||||||
|
object_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version?: string | null;
|
||||||
|
valid_at?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EvidenceReference = {
|
||||||
|
kind: string;
|
||||||
|
owner_module: string;
|
||||||
|
evidence_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormCondition =
|
||||||
|
| { kind: "predicate"; field_key: string; operator: string; value?: unknown }
|
||||||
|
| { kind: "all" | "any" | "not"; conditions: FormCondition[] };
|
||||||
|
|
||||||
|
export type FormFieldDefinition = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value_type: "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
||||||
|
required: boolean;
|
||||||
|
help_text?: string | null;
|
||||||
|
options: string[];
|
||||||
|
constraints: Record<string, unknown>;
|
||||||
|
default_value?: unknown;
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormDefinition = {
|
||||||
|
reference: InstitutionalReference;
|
||||||
|
key: string;
|
||||||
|
temporal: { revision: string; recorded_at?: string | null };
|
||||||
|
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?: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
sections: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
field_keys: string[];
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
fallback_locale?: string | null;
|
||||||
|
localizations?: Array<{
|
||||||
|
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 ValidationResult = {
|
||||||
|
field?: string | null;
|
||||||
|
severity: "warning" | "error";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormInstance = {
|
||||||
|
reference: InstitutionalReference;
|
||||||
|
tenant_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
revision: number;
|
||||||
|
status: string;
|
||||||
|
definition_ref: InstitutionalReference;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
validation_results: ValidationResult[];
|
||||||
|
attachment_refs: EvidenceReference[];
|
||||||
|
signature_refs: EvidenceReference[];
|
||||||
|
handoff_refs: InstitutionalReference[];
|
||||||
|
service_ref?: InstitutionalReference | null;
|
||||||
|
receipt_id?: string | null;
|
||||||
|
recorded_at: string;
|
||||||
|
change_reason: string;
|
||||||
|
created_by: string;
|
||||||
|
changed_by: string;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormInstanceEvent = {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
instance_revision: number;
|
||||||
|
status: string;
|
||||||
|
occurred_at: string;
|
||||||
|
actor_id: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormHandoff = {
|
||||||
|
effect_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
instance_revision: number;
|
||||||
|
binding_kind: "case" | "workflow";
|
||||||
|
binding_reference: string;
|
||||||
|
provider_capability: string;
|
||||||
|
state: "requested" | "accepted" | "rejected" | "outcome_unknown" | "reconciled" | "compensated";
|
||||||
|
attempt_count: number;
|
||||||
|
requested_at: string;
|
||||||
|
resolved_at?: string | null;
|
||||||
|
target_ref?: InstitutionalReference | null;
|
||||||
|
href?: string | null;
|
||||||
|
evidence: EvidenceReference[];
|
||||||
|
last_error?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listFormInstances(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ instances: FormInstance[]; total: number; offset: number; limit: number }> {
|
||||||
|
return apiFetch(settings, apiPath("/api/v1/forms-runtime/instances", {
|
||||||
|
status: options.statuses,
|
||||||
|
definition_id: options.definitionId,
|
||||||
|
offset: options.offset ?? 0,
|
||||||
|
limit: options.limit ?? 100
|
||||||
|
}), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormInstance(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDefinition(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<FormDefinition> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/definition`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormInstanceHistory(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ revisions: FormInstance[] }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/history`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormInstanceEvents(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ events: FormInstanceEvent[] }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/events`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFormDraft(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
changeReason: string
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
values,
|
||||||
|
attachment_refs: instance.attachment_refs,
|
||||||
|
signature_refs: instance.signature_refs,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: changeReason
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFormInstance(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
values,
|
||||||
|
attachment_refs: instance.attachment_refs,
|
||||||
|
signature_refs: instance.signature_refs,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFormHandoffs(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ handoffs: FormHandoff[] }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startFormHandoff(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
bindingKind: "case" | "workflow",
|
||||||
|
bindingReference?: string
|
||||||
|
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/handoffs/native`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
binding_kind: bindingKind,
|
||||||
|
binding_reference: bindingReference?.trim() || null,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
requested_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actOnFormHandoff(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
effectId: string,
|
||||||
|
action: "retry" | "reconcile"
|
||||||
|
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ recorded_at: new Date().toISOString() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compensateFormHandoff(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
effectId: string,
|
||||||
|
changeReason: string
|
||||||
|
): Promise<{ handoff: FormHandoff }> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/compensate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
confirmed_absent: true,
|
||||||
|
change_reason: changeReason
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
useGuardedNavigate,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getFormDefinition,
|
||||||
|
getFormInstance,
|
||||||
|
getFormInstanceEvents,
|
||||||
|
getFormInstanceHistory,
|
||||||
|
listFormHandoffs,
|
||||||
|
startFormHandoff,
|
||||||
|
actOnFormHandoff,
|
||||||
|
compensateFormHandoff,
|
||||||
|
saveFormDraft,
|
||||||
|
submitFormInstance,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormFieldDefinition,
|
||||||
|
type FormInstance,
|
||||||
|
type FormInstanceEvent,
|
||||||
|
type FormHandoff,
|
||||||
|
type ValidationResult
|
||||||
|
} from "../../api/formsRuntime";
|
||||||
|
import {
|
||||||
|
FORMS_RUNTIME_DOCUMENTATION,
|
||||||
|
FORMS_RUNTIME_FIELD_DOCUMENTATION,
|
||||||
|
FORMS_RUNTIME_I18N
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function FormInstancePage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const { instanceId = "" } = useParams();
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||||
|
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||||
|
const [history, setHistory] = useState<FormInstance[]>([]);
|
||||||
|
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
||||||
|
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||||
|
const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case");
|
||||||
|
const [handoffBinding, setHandoffBinding] = useState("");
|
||||||
|
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
|
||||||
|
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||||
|
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const nextInstance = await getFormInstance(settings, instanceId, signal);
|
||||||
|
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
|
||||||
|
getFormDefinition(settings, instanceId, signal),
|
||||||
|
getFormInstanceHistory(settings, instanceId, signal),
|
||||||
|
getFormInstanceEvents(settings, instanceId, signal),
|
||||||
|
listFormHandoffs(settings, instanceId, signal)
|
||||||
|
]);
|
||||||
|
setInstance(nextInstance);
|
||||||
|
setDefinition(nextDefinition);
|
||||||
|
setHistory(nextHistory.revisions);
|
||||||
|
setEvents(nextEvents.events);
|
||||||
|
setHandoffs(nextHandoffs.handoffs);
|
||||||
|
setValues(nextInstance.values);
|
||||||
|
setChangeReason("");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!definition) return;
|
||||||
|
if (!definition.handoff_kinds.includes(handoffKind)) {
|
||||||
|
setHandoffKind(definition.handoff_kinds.includes("case") ? "case" : "workflow");
|
||||||
|
}
|
||||||
|
}, [definition, handoffKind]);
|
||||||
|
|
||||||
|
const editableLifecycle = instance?.status === "started" || instance?.status === "draft";
|
||||||
|
const canParticipate = hasScope(auth, "forms_runtime:submission:participate");
|
||||||
|
const canWrite = hasScope(auth, "forms_runtime:workspace:write");
|
||||||
|
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||||
|
const canEditPermission = canParticipate || canWrite;
|
||||||
|
const editable = Boolean(editableLifecycle && canEditPermission);
|
||||||
|
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
|
||||||
|
const changed = useMemo(
|
||||||
|
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
|
||||||
|
[instance, values]
|
||||||
|
);
|
||||||
|
const diagnostics = useMemo(() => {
|
||||||
|
const grouped = new Map<string, ValidationResult[]>();
|
||||||
|
for (const item of instance?.validation_results ?? []) {
|
||||||
|
const key = item.field ?? "";
|
||||||
|
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [instance]);
|
||||||
|
const localized = useMemo(
|
||||||
|
() => localizeDefinition(definition, language),
|
||||||
|
[definition, language]
|
||||||
|
);
|
||||||
|
const groups = useMemo(
|
||||||
|
() => definition ? visibleGroups(definition, values) : [],
|
||||||
|
[definition, values]
|
||||||
|
);
|
||||||
|
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
|
||||||
|
const canHandoff = canWrite || canAdmin;
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await saveFormDraft(settings, instance, values, changeReason.trim());
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: Boolean(instance && (changed || changeReason)),
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => {
|
||||||
|
setValues(instance?.values ?? {});
|
||||||
|
setChangeReason("");
|
||||||
|
},
|
||||||
|
title: "i18n:govoplan-forms-runtime.unsaved_title",
|
||||||
|
message: "i18n:govoplan-forms-runtime.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!instance || !editable) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await submitFormInstance(settings, instance, values);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startHandoff() {
|
||||||
|
if (!instance || !mayHandoff) return;
|
||||||
|
setHandoffBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await startFormHandoff(settings, instance, handoffKind, handoffBinding);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The handoff could not be started.");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handoffAction(item: FormHandoff, action: "retry" | "reconcile") {
|
||||||
|
setHandoffBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await actOnFormHandoff(settings, item.instance_id, item.effect_id, action);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The handoff could not be updated.");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compensate() {
|
||||||
|
if (!compensating) return;
|
||||||
|
setHandoffBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await compensateFormHandoff(settings, compensating.instance_id, compensating.effect_id, "Operator confirmed that no target effect exists.");
|
||||||
|
setCompensating(null);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The handoff could not be compensated.");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="forms-runtime-page">
|
||||||
|
<div className="form-instance-shell">
|
||||||
|
<div className="form-instance-toolbar">
|
||||||
|
<Button onClick={() => navigate("/forms-runtime")}>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
|
Forms
|
||||||
|
</Button>
|
||||||
|
{definition && <strong>{localized.title}</strong>}
|
||||||
|
{instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||||
|
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
||||||
|
</div>
|
||||||
|
<PageScrollViewport className="form-instance-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading Form" />}
|
||||||
|
{!loading && instance && !editable &&
|
||||||
|
<ActionBlockerHint
|
||||||
|
tone="info"
|
||||||
|
reason={canEditPermission ? {
|
||||||
|
summary: "Read-only Form",
|
||||||
|
details: FORMS_RUNTIME_I18N.lifecycleReason
|
||||||
|
} : {
|
||||||
|
summary: "Read-only Form",
|
||||||
|
details: FORMS_RUNTIME_I18N.editReason,
|
||||||
|
requiredAction: FORMS_RUNTIME_I18N.permissionAction,
|
||||||
|
actor: FORMS_RUNTIME_I18N.permissionActor,
|
||||||
|
target: FORMS_RUNTIME_I18N.permissionDestination
|
||||||
|
}}
|
||||||
|
labels={{ requiredAction: FORMS_RUNTIME_I18N.requiredAction, actor: FORMS_RUNTIME_I18N.actor, target: FORMS_RUNTIME_I18N.destination }}
|
||||||
|
documentation={FORMS_RUNTIME_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{!loading && instance && definition &&
|
||||||
|
<div className="form-instance-content">
|
||||||
|
<section className="form-instance-main">
|
||||||
|
<header>
|
||||||
|
<h1>{localized.title}</h1>
|
||||||
|
{localized.description && <p>{localized.description}</p>}
|
||||||
|
</header>
|
||||||
|
{groups.map((group) =>
|
||||||
|
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
||||||
|
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
|
||||||
|
<div className="form-fields">
|
||||||
|
{group.fields.map((field) =>
|
||||||
|
<FormField
|
||||||
|
key={field.key}
|
||||||
|
field={{
|
||||||
|
...field,
|
||||||
|
label: localized.fieldLabels[field.key] ?? field.label,
|
||||||
|
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text,
|
||||||
|
options: field.options
|
||||||
|
}}
|
||||||
|
optionLabels={localized.optionLabels[field.key] ?? {}}
|
||||||
|
value={values[field.key]}
|
||||||
|
disabled={!editable || saving}
|
||||||
|
diagnostics={diagnostics.get(field.key) ?? []}
|
||||||
|
onChange={(value) => setValues((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value === undefined || value === "") delete next[field.key];
|
||||||
|
else next[field.key] = value;
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
||||||
|
<div className="form-evidence-summary">
|
||||||
|
<span>{instance.attachment_refs.length} attachments</span>
|
||||||
|
<span>{instance.signature_refs.length} signatures</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{editable &&
|
||||||
|
<div className="form-instance-actions">
|
||||||
|
<DocumentationHelpLink reference={FORMS_RUNTIME_FIELD_DOCUMENTATION} />
|
||||||
|
{canSave &&
|
||||||
|
<label className="form-change-reason">
|
||||||
|
<span>Change reason</span>
|
||||||
|
<input
|
||||||
|
value={changeReason}
|
||||||
|
onChange={(event) => setChangeReason(event.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
{canSave &&
|
||||||
|
<Button
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={!changed || !changeReason.trim() || saving}
|
||||||
|
disabledReason={saving ? FORMS_RUNTIME_I18N.saving : !changed ? FORMS_RUNTIME_I18N.unchanged : !changeReason.trim() ? FORMS_RUNTIME_I18N.changeReason : undefined}>
|
||||||
|
<Save size={16} aria-hidden="true" />
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : undefined}>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!editable && instance.receipt_id &&
|
||||||
|
<div className="form-receipt">
|
||||||
|
<span>Submission receipt</span>
|
||||||
|
<code>{instance.receipt_id}</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!editable && definition.handoff_kinds.length > 0 &&
|
||||||
|
<section className="form-handoffs">
|
||||||
|
<div className="form-handoff-heading">
|
||||||
|
<h2>Case and workflow handoffs</h2>
|
||||||
|
{mayHandoff &&
|
||||||
|
<div className="form-handoff-create">
|
||||||
|
<select value={handoffKind} disabled={handoffBusy} onChange={(event) => setHandoffKind(event.target.value as "case" | "workflow")}>
|
||||||
|
{definition.handoff_kinds.includes("case") && <option value="case">Create Case</option>}
|
||||||
|
{definition.handoff_kinds.includes("workflow") && <option value="workflow">Start Workflow</option>}
|
||||||
|
</select>
|
||||||
|
<input value={handoffBinding} disabled={handoffBusy} onChange={(event) => setHandoffBinding(event.target.value)} placeholder="Target binding (optional)" aria-label="Exact target binding" />
|
||||||
|
<Button variant="primary" disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => setConfirmingHandoff(true)}><Send size={15} aria-hidden="true" />Start</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{handoffs.length === 0 && <p className="form-handoff-empty">No handoff has been requested.</p>}
|
||||||
|
<div className="form-handoff-list">
|
||||||
|
{handoffs.map((item) =>
|
||||||
|
<div className="form-handoff-row" key={item.effect_id}>
|
||||||
|
<span><strong>{domainLabel(item.binding_kind)}</strong><small>{item.binding_reference}</small></span>
|
||||||
|
<StatusBadge status={item.state === "accepted" || item.state === "reconciled" ? "active" : "inactive"} label={stateLabel(item.state)} />
|
||||||
|
{item.last_error && <span className="form-handoff-error">{item.last_error}</span>}
|
||||||
|
<span className="form-handoff-actions">
|
||||||
|
{item.href && <Button onClick={() => navigate(item.href!)}><ExternalLink size={15} aria-hidden="true" />Open</Button>}
|
||||||
|
{item.state === "rejected" && <Button disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => void handoffAction(item, "retry")}><RefreshCw size={15} aria-hidden="true" />Retry</Button>}
|
||||||
|
{item.state === "outcome_unknown" && <Button disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => void handoffAction(item, "reconcile")}><RefreshCw size={15} aria-hidden="true" />Reconcile</Button>}
|
||||||
|
{canAdmin && (item.state === "rejected" || item.state === "outcome_unknown") && <Button variant="danger" disabled={handoffBusy} onClick={() => setCompensating(item)}>Compensate</Button>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<aside className="form-instance-aside">
|
||||||
|
<section>
|
||||||
|
<h2>Status history</h2>
|
||||||
|
<ol>
|
||||||
|
{events.map((event) =>
|
||||||
|
<li key={event.event_id}>
|
||||||
|
<strong>{stateLabel(event.status)}</strong>
|
||||||
|
<span>{humanize(event.event_type)}</span>
|
||||||
|
<time>{formatDateTime(event.occurred_at, language)}</time>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Revisions</h2>
|
||||||
|
<ol>
|
||||||
|
{history.map((item) =>
|
||||||
|
<li key={item.revision}>
|
||||||
|
<strong>Revision {item.revision}</strong>
|
||||||
|
<span>{item.change_reason}</span>
|
||||||
|
<time>{formatDateTime(item.recorded_at, language)}</time>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmingSubmit}
|
||||||
|
title="i18n:govoplan-forms-runtime.submit_title"
|
||||||
|
message="i18n:govoplan-forms-runtime.submit_message"
|
||||||
|
confirmLabel="Submit"
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => setConfirmingSubmit(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmingSubmit(false);
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmingHandoff}
|
||||||
|
title="i18n:govoplan-forms-runtime.handoff_title"
|
||||||
|
message={i18nMessage("i18n:govoplan-forms-runtime.handoff_message", { kind: domainLabel(handoffKind) })}
|
||||||
|
confirmLabel="Start"
|
||||||
|
busy={handoffBusy}
|
||||||
|
onCancel={() => setConfirmingHandoff(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmingHandoff(false);
|
||||||
|
void startHandoff();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(compensating)}
|
||||||
|
title="Compensate handoff"
|
||||||
|
message="Confirm only after verifying that no Case or Workflow target exists. This records an administrative recovery decision; it does not delete a remote target."
|
||||||
|
confirmLabel="Confirm absent and compensate"
|
||||||
|
tone="danger"
|
||||||
|
busy={handoffBusy}
|
||||||
|
onCancel={() => setCompensating(null)}
|
||||||
|
onConfirm={() => void compensate()}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
diagnostics,
|
||||||
|
optionLabels,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
field: FormFieldDefinition;
|
||||||
|
value: unknown;
|
||||||
|
disabled: boolean;
|
||||||
|
diagnostics: ValidationResult[];
|
||||||
|
optionLabels: Record<string, string>;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
|
||||||
|
if (field.value_type === "boolean") {
|
||||||
|
return (
|
||||||
|
<div className="form-field form-field-toggle">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={`${field.label}${field.required ? " (required)" : ""}`}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
help={field.help_text ?? undefined}
|
||||||
|
/>
|
||||||
|
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="form-field">
|
||||||
|
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
|
||||||
|
{field.help_text && <small>{field.help_text}</small>}
|
||||||
|
{renderInput(field, value, disabled, describedBy, onChange, optionLabels)}
|
||||||
|
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInput(
|
||||||
|
field: FormFieldDefinition,
|
||||||
|
value: unknown,
|
||||||
|
disabled: boolean,
|
||||||
|
describedBy: string | undefined,
|
||||||
|
onChange: (value: unknown) => void,
|
||||||
|
optionLabels: Record<string, string>
|
||||||
|
) {
|
||||||
|
const common = { disabled, required: field.required, "aria-describedby": describedBy };
|
||||||
|
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
{...common}
|
||||||
|
rows={field.value_type === "multiline_text" ? 5 : 7}
|
||||||
|
value={structuredValue(value)}
|
||||||
|
onChange={(event) => onChange(field.value_type === "multiline_text" ? event.target.value : parseStructured(event.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.value_type === "choice") {
|
||||||
|
return (
|
||||||
|
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.value_type === "multi_choice") {
|
||||||
|
const selected = Array.isArray(value) ? value.map(String) : [];
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
{...common}
|
||||||
|
multiple
|
||||||
|
value={selected}
|
||||||
|
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
|
||||||
|
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const type = field.value_type === "integer" || field.value_type === "number"
|
||||||
|
? "number"
|
||||||
|
: field.value_type === "email"
|
||||||
|
? "email"
|
||||||
|
: field.value_type === "date"
|
||||||
|
? "date"
|
||||||
|
: field.value_type === "datetime"
|
||||||
|
? "datetime-local"
|
||||||
|
: "text";
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...common}
|
||||||
|
type={type}
|
||||||
|
step={field.value_type === "integer" ? 1 : field.value_type === "number" ? "any" : undefined}
|
||||||
|
min={numericConstraint(field.constraints.minimum)}
|
||||||
|
max={numericConstraint(field.constraints.maximum)}
|
||||||
|
minLength={numericConstraint(field.constraints.min_length)}
|
||||||
|
maxLength={numericConstraint(field.constraints.max_length)}
|
||||||
|
value={inputValue(field, value)}
|
||||||
|
onChange={(event) => onChange(inputChangeValue(field, event.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldMessages({ id, diagnostics }: { id?: string; diagnostics: ValidationResult[] }) {
|
||||||
|
if (diagnostics.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span id={id} className="form-field-messages" aria-live="polite">
|
||||||
|
{diagnostics.map((item) => <small key={item.code} data-severity={item.severity}>{item.message}</small>)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputValue(field: FormFieldDefinition, value: unknown): string | number {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (field.value_type === "datetime" && typeof value === "string") {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!Number.isNaN(parsed.valueOf())) {
|
||||||
|
const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === "number" || typeof value === "string" ? value : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChangeValue(field: FormFieldDefinition, value: string): unknown {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (field.value_type === "integer") return Number.parseInt(value, 10);
|
||||||
|
if (field.value_type === "number") return Number(value);
|
||||||
|
if (field.value_type === "datetime") return new Date(value).toISOString();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function structuredValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStructured(value: string): unknown {
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericConstraint(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
|
||||||
|
const fields = new Map(definition.fields.map((field) => [field.key, field]));
|
||||||
|
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
|
||||||
|
if (!definition.pages?.length) {
|
||||||
|
return [{
|
||||||
|
pageKey: "page",
|
||||||
|
sectionKey: "section",
|
||||||
|
sectionTitle: definition.title,
|
||||||
|
description: definition.description,
|
||||||
|
fields: definition.fields.filter(fieldVisible)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return definition.pages.flatMap((page) => {
|
||||||
|
if (page.visibility_condition && !evaluateCondition(page.visibility_condition, values)) return [];
|
||||||
|
return page.sections.flatMap((section) => {
|
||||||
|
if (section.visibility_condition && !evaluateCondition(section.visibility_condition, values)) return [];
|
||||||
|
return [{
|
||||||
|
pageKey: page.key,
|
||||||
|
sectionKey: section.key,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
description: section.description,
|
||||||
|
fields: section.field_keys.map((key) => fields.get(key)).filter((field): field is FormFieldDefinition => Boolean(field && fieldVisible(field)))
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCondition(condition: NonNullable<FormFieldDefinition["visibility_condition"]>, values: Record<string, unknown>): boolean {
|
||||||
|
if (condition.kind === "all") return condition.conditions.every((item) => evaluateCondition(item, values));
|
||||||
|
if (condition.kind === "any") return condition.conditions.some((item) => evaluateCondition(item, values));
|
||||||
|
if (condition.kind === "not") return !evaluateCondition(condition.conditions[0], values);
|
||||||
|
const actual = values[condition.field_key];
|
||||||
|
const expected = condition.value;
|
||||||
|
switch (condition.operator) {
|
||||||
|
case "eq": return actual === expected;
|
||||||
|
case "neq": return actual !== expected;
|
||||||
|
case "is_empty": return emptyValue(actual);
|
||||||
|
case "is_not_empty": return !emptyValue(actual);
|
||||||
|
case "in": return Array.isArray(expected) && expected.includes(actual);
|
||||||
|
case "not_in": return Array.isArray(expected) && !expected.includes(actual);
|
||||||
|
case "contains": return typeof actual === "string" ? actual.includes(String(expected ?? "")) : Array.isArray(actual) && actual.includes(expected);
|
||||||
|
case "lt": return comparable(actual, expected, (left, right) => left < right);
|
||||||
|
case "lte": return comparable(actual, expected, (left, right) => left <= right);
|
||||||
|
case "gt": return comparable(actual, expected, (left, right) => left > right);
|
||||||
|
case "gte": return comparable(actual, expected, (left, right) => left >= right);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyValue(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparable(left: unknown, right: unknown, compare: (left: number | string, right: number | string) => boolean): boolean {
|
||||||
|
if ((typeof left === "number" && typeof right === "number") || (typeof left === "string" && typeof right === "string")) {
|
||||||
|
return compare(left, right);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeDefinition(definition: FormDefinition | null, language: string) {
|
||||||
|
const canonical = {
|
||||||
|
title: definition?.title ?? "",
|
||||||
|
description: definition?.description ?? null,
|
||||||
|
fieldLabels: {} as Record<string, string>,
|
||||||
|
fieldHelpTexts: {} as Record<string, string>,
|
||||||
|
optionLabels: {} as Record<string, Record<string, string>>,
|
||||||
|
sectionTitles: {} as Record<string, string>
|
||||||
|
};
|
||||||
|
if (!definition?.localizations?.length) return canonical;
|
||||||
|
const requestedLocale = language.toLowerCase();
|
||||||
|
const localization = definition.localizations.find((item) => requestedLocale === item.locale.toLowerCase() || requestedLocale.startsWith(`${item.locale.toLowerCase()}-`))
|
||||||
|
?? definition.localizations.find((item) => item.locale.toLowerCase() === definition.fallback_locale?.toLowerCase());
|
||||||
|
if (!localization) return canonical;
|
||||||
|
return {
|
||||||
|
title: localization.title || canonical.title,
|
||||||
|
description: localization.description || canonical.description,
|
||||||
|
fieldLabels: localization.field_labels,
|
||||||
|
fieldHelpTexts: localization.field_help_texts,
|
||||||
|
optionLabels: localization.option_labels,
|
||||||
|
sectionTitles: localization.section_titles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string, locale?: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(value: string): string {
|
||||||
|
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainLabel(value: string): string {
|
||||||
|
return `i18n:govoplan-forms-runtime.domain_${value}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
i18nMessage,
|
||||||
|
useGuardedNavigate,
|
||||||
|
usePlatformLanguage,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||||
|
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||||
|
|
||||||
|
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const [items, setItems] = useState<FormInstance[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [status, setStatus] = useState("open");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
return listFormInstances(settings, {
|
||||||
|
statuses: status === "open" ? OPEN_STATUSES : status ? [status] : undefined,
|
||||||
|
limit: 200
|
||||||
|
}, signal).
|
||||||
|
then((result) => {
|
||||||
|
setItems(result.instances);
|
||||||
|
setTotal(result.total);
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
}, [settings, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Forms could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="forms-runtime-page">
|
||||||
|
<div className="forms-runtime-shell">
|
||||||
|
<div className="forms-runtime-toolbar">
|
||||||
|
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_RUNTIME_I18N.loading : undefined}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<label>
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="needs_review">Needs review</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
<option value="handed_off">Handed off</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="forms-runtime-count">{i18nMessage("i18n:govoplan-forms-runtime.form_count", { total })}</span>
|
||||||
|
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
||||||
|
</div>
|
||||||
|
<PageScrollViewport className="forms-runtime-list-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading forms" />}
|
||||||
|
{!loading && !error && items.length === 0 &&
|
||||||
|
<div className="forms-runtime-empty">No matching Forms.</div>
|
||||||
|
}
|
||||||
|
{!loading && items.length > 0 &&
|
||||||
|
<div className="forms-runtime-list" role="list">
|
||||||
|
{items.map((item) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className="forms-runtime-row"
|
||||||
|
key={item.instance_id}
|
||||||
|
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
|
||||||
|
<span className="forms-runtime-row-main">
|
||||||
|
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
|
||||||
|
<span>Revision {item.definition_ref.version ?? "-"}</span>
|
||||||
|
</span>
|
||||||
|
<span>{formatDateTime(item.recorded_at, language)}</span>
|
||||||
|
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={stateLabel(item.status)} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen(status: string): boolean {
|
||||||
|
return OPEN_STATUSES.includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string, locale?: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(value: string): string {
|
||||||
|
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const FORMS_RUNTIME_DOCUMENTATION = {
|
||||||
|
topicId: "forms_runtime.submissions",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FORMS_RUNTIME_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "forms_runtime.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FORMS_RUNTIME_I18N = {
|
||||||
|
loading: "i18n:govoplan-forms-runtime.loading_reason",
|
||||||
|
saving: "i18n:govoplan-forms-runtime.saving_reason",
|
||||||
|
editReason: "i18n:govoplan-forms-runtime.edit_permission_reason",
|
||||||
|
handoffReason: "i18n:govoplan-forms-runtime.handoff_permission_reason",
|
||||||
|
lifecycleReason: "i18n:govoplan-forms-runtime.lifecycle_reason",
|
||||||
|
unchanged: "i18n:govoplan-forms-runtime.unchanged_reason",
|
||||||
|
changeReason: "i18n:govoplan-forms-runtime.change_reason_required",
|
||||||
|
requiredAction: "i18n:govoplan-forms-runtime.required_action",
|
||||||
|
actor: "i18n:govoplan-forms-runtime.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-forms-runtime.destination",
|
||||||
|
permissionAction: "i18n:govoplan-forms-runtime.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-forms-runtime.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-forms-runtime.permission_destination"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-forms-runtime.forms": "Forms",
|
||||||
|
"i18n:govoplan-forms-runtime.loading_reason": "Form data is still loading.",
|
||||||
|
"i18n:govoplan-forms-runtime.saving_reason": "A Form operation is still running.",
|
||||||
|
"i18n:govoplan-forms-runtime.edit_permission_reason": "Your account may read this Form but may not change or submit it.",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_permission_reason": "Your account may not create or recover Case and Workflow handoffs.",
|
||||||
|
"i18n:govoplan-forms-runtime.lifecycle_reason": "This Form is immutable in its current lifecycle state.",
|
||||||
|
"i18n:govoplan-forms-runtime.unchanged_reason": "There are no changed values to save.",
|
||||||
|
"i18n:govoplan-forms-runtime.change_reason_required": "Enter a change reason before saving the draft.",
|
||||||
|
"i18n:govoplan-forms-runtime.required_action": "Required action",
|
||||||
|
"i18n:govoplan-forms-runtime.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-forms-runtime.destination": "Destination",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_action": "Ask for the appropriate Forms Runtime permission or assignment.",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_actor": "An Access administrator or the responsible process owner",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_destination": "Access role assignments or the assigning workflow",
|
||||||
|
"i18n:govoplan-forms-runtime.unsaved_title": "Unsaved Form",
|
||||||
|
"i18n:govoplan-forms-runtime.unsaved_message": "Save or discard the changed Form values before leaving this surface.",
|
||||||
|
"i18n:govoplan-forms-runtime.submit_title": "Submit Form",
|
||||||
|
"i18n:govoplan-forms-runtime.submit_message": "Submit this Form? The server validates the exact published definition and records an immutable receipt.",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_title": "Start governed handoff",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_message": "Start the {kind} handoff? Intent is recorded before execution and an unknown outcome requires reconciliation.",
|
||||||
|
"i18n:govoplan-forms-runtime.form_count": "{total} forms",
|
||||||
|
"i18n:govoplan-forms-runtime.state_started": "Started",
|
||||||
|
"i18n:govoplan-forms-runtime.state_draft": "Draft",
|
||||||
|
"i18n:govoplan-forms-runtime.state_submitted": "Submitted",
|
||||||
|
"i18n:govoplan-forms-runtime.state_validated": "Validated",
|
||||||
|
"i18n:govoplan-forms-runtime.state_needs_review": "Needs review",
|
||||||
|
"i18n:govoplan-forms-runtime.state_accepted": "Accepted",
|
||||||
|
"i18n:govoplan-forms-runtime.state_rejected": "Rejected",
|
||||||
|
"i18n:govoplan-forms-runtime.state_handed_off": "Handed off",
|
||||||
|
"i18n:govoplan-forms-runtime.state_archived": "Archived",
|
||||||
|
"i18n:govoplan-forms-runtime.state_reconciled": "Reconciled",
|
||||||
|
"i18n:govoplan-forms-runtime.state_outcome_unknown": "Outcome unknown",
|
||||||
|
"i18n:govoplan-forms-runtime.state_requested": "Requested",
|
||||||
|
"i18n:govoplan-forms-runtime.state_compensated": "Compensated",
|
||||||
|
"i18n:govoplan-forms-runtime.domain_case": "Case",
|
||||||
|
"i18n:govoplan-forms-runtime.domain_workflow": "Workflow",
|
||||||
|
"Forms": "Forms",
|
||||||
|
"Refresh": "Refresh",
|
||||||
|
"Status": "Status",
|
||||||
|
"Open": "Open",
|
||||||
|
"All": "All",
|
||||||
|
"Draft": "Draft",
|
||||||
|
"Submitted": "Submitted",
|
||||||
|
"Needs review": "Needs review",
|
||||||
|
"Accepted": "Accepted",
|
||||||
|
"Rejected": "Rejected",
|
||||||
|
"Handed off": "Handed off",
|
||||||
|
"Archived": "Archived",
|
||||||
|
"Loading forms": "Loading forms",
|
||||||
|
"No matching Forms.": "No matching Forms.",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Loading Form": "Loading Form",
|
||||||
|
"Change reason": "Change reason",
|
||||||
|
"Save draft": "Save draft",
|
||||||
|
"Submit": "Submit",
|
||||||
|
"Submission receipt": "Submission receipt",
|
||||||
|
"Case and workflow handoffs": "Case and workflow handoffs",
|
||||||
|
"Create Case": "Create Case",
|
||||||
|
"Start Workflow": "Start Workflow",
|
||||||
|
"Target binding (optional)": "Target binding (optional)",
|
||||||
|
"Exact target binding": "Exact target binding",
|
||||||
|
"Start": "Start",
|
||||||
|
"No handoff has been requested.": "No handoff has been requested.",
|
||||||
|
"Open target": "Open target",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"Reconcile": "Reconcile",
|
||||||
|
"Compensate": "Compensate",
|
||||||
|
"Status history": "Status history",
|
||||||
|
"Revisions": "Revisions",
|
||||||
|
"Select": "Select",
|
||||||
|
"Compensate handoff": "Compensate handoff",
|
||||||
|
"Confirm absent and compensate": "Confirm absent and compensate",
|
||||||
|
"Read-only Form": "Read-only Form"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-forms-runtime.forms": "Formulare",
|
||||||
|
"i18n:govoplan-forms-runtime.loading_reason": "Formulardaten werden noch geladen.",
|
||||||
|
"i18n:govoplan-forms-runtime.saving_reason": "Eine Formularaktion läuft noch.",
|
||||||
|
"i18n:govoplan-forms-runtime.edit_permission_reason": "Ihr Konto darf dieses Formular lesen, aber nicht ändern oder absenden.",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_permission_reason": "Ihr Konto darf keine Fall- oder Workflow-Übergaben erstellen oder wiederherstellen.",
|
||||||
|
"i18n:govoplan-forms-runtime.lifecycle_reason": "Dieses Formular ist in seinem aktuellen Lebenszyklus unveränderlich.",
|
||||||
|
"i18n:govoplan-forms-runtime.unchanged_reason": "Es gibt keine geänderten Werte zu speichern.",
|
||||||
|
"i18n:govoplan-forms-runtime.change_reason_required": "Geben Sie vor dem Speichern des Entwurfs einen Änderungsgrund ein.",
|
||||||
|
"i18n:govoplan-forms-runtime.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-forms-runtime.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-forms-runtime.destination": "Ziel",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_action": "Fordern Sie die passende Formularberechtigung oder Zuweisung an.",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_actor": "Eine Zugriffsadministration oder die verantwortliche Prozessstelle",
|
||||||
|
"i18n:govoplan-forms-runtime.permission_destination": "Zugriff und Rollenzuweisungen oder der zuweisende Workflow",
|
||||||
|
"i18n:govoplan-forms-runtime.unsaved_title": "Ungespeichertes Formular",
|
||||||
|
"i18n:govoplan-forms-runtime.unsaved_message": "Speichern oder verwerfen Sie die geänderten Formularwerte, bevor Sie diese Oberfläche verlassen.",
|
||||||
|
"i18n:govoplan-forms-runtime.submit_title": "Formular absenden",
|
||||||
|
"i18n:govoplan-forms-runtime.submit_message": "Dieses Formular absenden? Der Server prüft die exakte veröffentlichte Definition und erfasst einen unveränderlichen Beleg.",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_title": "Geregelte Übergabe starten",
|
||||||
|
"i18n:govoplan-forms-runtime.handoff_message": "Die Übergabe an {kind} starten? Die Absicht wird vor der Ausführung erfasst; ein unbekanntes Ergebnis muss abgeglichen werden.",
|
||||||
|
"i18n:govoplan-forms-runtime.form_count": "{total} Formulare",
|
||||||
|
"i18n:govoplan-forms-runtime.state_started": "Gestartet",
|
||||||
|
"i18n:govoplan-forms-runtime.state_draft": "Entwurf",
|
||||||
|
"i18n:govoplan-forms-runtime.state_submitted": "Abgesendet",
|
||||||
|
"i18n:govoplan-forms-runtime.state_validated": "Validiert",
|
||||||
|
"i18n:govoplan-forms-runtime.state_needs_review": "Prüfung erforderlich",
|
||||||
|
"i18n:govoplan-forms-runtime.state_accepted": "Angenommen",
|
||||||
|
"i18n:govoplan-forms-runtime.state_rejected": "Abgelehnt",
|
||||||
|
"i18n:govoplan-forms-runtime.state_handed_off": "Übergeben",
|
||||||
|
"i18n:govoplan-forms-runtime.state_archived": "Archiviert",
|
||||||
|
"i18n:govoplan-forms-runtime.state_reconciled": "Abgeglichen",
|
||||||
|
"i18n:govoplan-forms-runtime.state_outcome_unknown": "Ergebnis unbekannt",
|
||||||
|
"i18n:govoplan-forms-runtime.state_requested": "Angefordert",
|
||||||
|
"i18n:govoplan-forms-runtime.state_compensated": "Kompensiert",
|
||||||
|
"i18n:govoplan-forms-runtime.domain_case": "Fall",
|
||||||
|
"i18n:govoplan-forms-runtime.domain_workflow": "Workflow",
|
||||||
|
"Forms": "Formulare",
|
||||||
|
"Refresh": "Aktualisieren",
|
||||||
|
"Status": "Status",
|
||||||
|
"Open": "Offen",
|
||||||
|
"All": "Alle",
|
||||||
|
"Draft": "Entwurf",
|
||||||
|
"Submitted": "Abgesendet",
|
||||||
|
"Needs review": "Prüfung erforderlich",
|
||||||
|
"Accepted": "Angenommen",
|
||||||
|
"Rejected": "Abgelehnt",
|
||||||
|
"Handed off": "Übergeben",
|
||||||
|
"Archived": "Archiviert",
|
||||||
|
"Loading forms": "Formulare werden geladen",
|
||||||
|
"No matching Forms.": "Keine passenden Formulare.",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Loading Form": "Formular wird geladen",
|
||||||
|
"Change reason": "Änderungsgrund",
|
||||||
|
"Save draft": "Entwurf speichern",
|
||||||
|
"Submit": "Absenden",
|
||||||
|
"Submission receipt": "Übermittlungsbeleg",
|
||||||
|
"Case and workflow handoffs": "Fall- und Workflow-Übergaben",
|
||||||
|
"Create Case": "Fall erstellen",
|
||||||
|
"Start Workflow": "Workflow starten",
|
||||||
|
"Target binding (optional)": "Zielbindung (optional)",
|
||||||
|
"Exact target binding": "Exakte Zielbindung",
|
||||||
|
"Start": "Starten",
|
||||||
|
"No handoff has been requested.": "Es wurde keine Übergabe angefordert.",
|
||||||
|
"Open target": "Ziel öffnen",
|
||||||
|
"Retry": "Erneut versuchen",
|
||||||
|
"Reconcile": "Abgleichen",
|
||||||
|
"Compensate": "Kompensieren",
|
||||||
|
"Status history": "Statusverlauf",
|
||||||
|
"Revisions": "Revisionen",
|
||||||
|
"Select": "Auswählen",
|
||||||
|
"Compensate handoff": "Übergabe kompensieren",
|
||||||
|
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
|
||||||
|
"Read-only Form": "Schreibgeschütztes Formular"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, formsRuntimeModule } from "./module";
|
||||||
|
export * from "./api/formsRuntime";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/forms-runtime.css";
|
||||||
|
|
||||||
|
|
||||||
|
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
||||||
|
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
||||||
|
const routeScopes = [
|
||||||
|
"forms_runtime:submission:participate",
|
||||||
|
"forms_runtime:workspace:read"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const formsRuntimeModule: PlatformWebModule = {
|
||||||
|
id: "forms_runtime",
|
||||||
|
label: "i18n:govoplan-forms-runtime.forms",
|
||||||
|
version: "0.1.14",
|
||||||
|
dependencies: ["access", "forms"],
|
||||||
|
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
|
||||||
|
translations: generatedTranslations,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/forms-runtime",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 37,
|
||||||
|
surfaceId: "forms_runtime.workspace",
|
||||||
|
render: (context) => createElement(FormsRuntimePage, context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/forms-runtime/:instanceId",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 38,
|
||||||
|
surfaceId: "forms_runtime.instance",
|
||||||
|
render: (context) => createElement(FormInstancePage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/forms-runtime",
|
||||||
|
label: "i18n:govoplan-forms-runtime.forms",
|
||||||
|
iconName: "form",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 37,
|
||||||
|
surfaceId: "forms_runtime.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "forms_runtime.navigation", moduleId: "forms_runtime", kind: "navigation", label: "Forms navigation", order: 10 },
|
||||||
|
{ id: "forms_runtime.workspace", moduleId: "forms_runtime", kind: "route", label: "Forms workspace", order: 20 },
|
||||||
|
{ id: "forms_runtime.instance", moduleId: "forms_runtime", kind: "route", label: "Form instance", order: 30 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default formsRuntimeModule;
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
.forms-runtime-page,
|
||||||
|
.forms-runtime-shell,
|
||||||
|
.form-instance-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-shell,
|
||||||
|
.form-instance-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar,
|
||||||
|
.form-instance-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar label > span,
|
||||||
|
.forms-runtime-count {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-count,
|
||||||
|
.form-instance-toolbar .status-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-list-viewport,
|
||||||
|
.form-instance-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-viewport > .action-blocker-hint {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-list {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row:hover,
|
||||||
|
.forms-runtime-row:focus-visible {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main strong,
|
||||||
|
.forms-runtime-row-main span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main span,
|
||||||
|
.forms-runtime-row > span:not(.status-badge) {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-empty {
|
||||||
|
padding: 36px 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: 28px;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main,
|
||||||
|
.form-instance-aside {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main > header {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main > header p {
|
||||||
|
max-width: 70ch;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px 18px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-runtime-section > header {
|
||||||
|
padding-top: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-runtime-section > header h2,
|
||||||
|
.form-handoffs h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-runtime-section > header p {
|
||||||
|
margin: 5px 0 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:has(textarea),
|
||||||
|
.form-field:has(select[multiple]) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > span:first-child {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > span b {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field select[multiple] {
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-toggle {
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages [data-severity="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages [data-severity="warning"] {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-evidence-summary,
|
||||||
|
.form-receipt {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 18px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-receipt {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-receipt code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoffs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-heading,
|
||||||
|
.form-handoff-create,
|
||||||
|
.form-handoff-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-create input {
|
||||||
|
width: min(260px, 34vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) auto minmax(180px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 54px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-row > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-row small,
|
||||||
|
.form-handoff-error {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-change-reason {
|
||||||
|
display: flex;
|
||||||
|
min-width: min(360px, 100%);
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-change-reason span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside section + section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 0 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside li span,
|
||||||
|
.form-instance-aside li time {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.forms-runtime-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:has(textarea),
|
||||||
|
.form-field:has(select[multiple]) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-create,
|
||||||
|
.form-handoff-row {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-create {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-handoff-create input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user