Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3232f6e12 | ||
|
|
ad9a580ad3 | ||
|
|
1ac44af8d0 | ||
|
|
9bea71d87e | ||
|
|
532e614f7b | ||
|
|
b7c2411819 | ||
|
|
7f64247625 | ||
|
|
3abbe117e5 | ||
|
|
24e95591e0 | ||
|
|
62d0f300cb | ||
|
|
2b081cb931 | ||
|
|
805ac1c8de | ||
|
|
d12e400b61 |
@@ -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 Approvals Codex Guide
|
# GovOPlaN Approvals 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 Approvals 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 Approvals platform module seed.
|
This repository owns the GovOPlaN Approvals platform module seed.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# GovOPlaN Approvals
|
# GovOPlaN Approvals
|
||||||
|
|
||||||
`govoplan-approvals` is the GovOPlaN platform module seed for generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
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.
|
`govoplan-approvals` owns generic approval and sign-off chains with trusted delegation, separation of duties, escalation, and signature references. It persists immutable request revisions, append-only decisions and lifecycle evidence, exposes a tenant-scoped API and WebUI, and provides the `approvals.requests` capability for exact-subject approval checks.
|
||||||
|
|
||||||
## Initial Ownership
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -23,11 +25,24 @@ This module does not own:
|
|||||||
|
|
||||||
Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVALS_DOMAIN_BOUNDARY.md).
|
Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVALS_DOMAIN_BOUNDARY.md).
|
||||||
|
|
||||||
|
Tenant approval administrators manage reusable chains under
|
||||||
|
`Admin > Tenant > Approval templates`. Template edits and publication create
|
||||||
|
immutable revisions with content hashes and actor provenance. The history API
|
||||||
|
and UI compare any two revisions as structured JSON-pointer changes. The
|
||||||
|
request workspace also exposes due-step escalation to approval administrators;
|
||||||
|
the server rechecks the due timestamp and request revision before recording the
|
||||||
|
transition.
|
||||||
|
|
||||||
|
Template API additions:
|
||||||
|
|
||||||
|
- `GET /api/v1/approvals/templates/{template_id}/history`
|
||||||
|
- `GET /api/v1/approvals/templates/{template_id}/compare`
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Expected optional integrations:
|
Optional integrations:
|
||||||
|
|
||||||
- workflow
|
- workflow engine
|
||||||
- audit
|
- audit
|
||||||
- files
|
- files
|
||||||
- notifications
|
- notifications
|
||||||
@@ -41,7 +56,7 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
./.venv/bin/python -m pip install -e ../govoplan-approvals
|
./.venv/bin/python -m pip install -e ../govoplan-approvals
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused runtime verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-approvals
|
cd /mnt/DATA/git/govoplan-approvals
|
||||||
@@ -56,5 +71,5 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-approvals --apply
|
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-approvals --apply
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,44 +1,50 @@
|
|||||||
# Approvals Domain Boundary
|
# Approvals domain boundary and operations
|
||||||
|
|
||||||
## Purpose
|
Approvals owns generic, reusable sign-off chains. A request freezes an exact
|
||||||
|
module-owned subject revision, ordered steps, eligible actor selectors,
|
||||||
|
required counts, rejection behavior, due dates, signature requirements,
|
||||||
|
separation-of-duties rules, and policy references.
|
||||||
|
|
||||||
Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.
|
Approvals does not own the subject's business state. A Campaign remains a
|
||||||
|
Campaign and a Decision remains a Decision. Consumers call
|
||||||
|
`approvals.requests.check_approved` with the exact subject identity and version
|
||||||
|
before performing their consequential transition. A previously approved
|
||||||
|
request cannot authorize a changed subject revision.
|
||||||
|
|
||||||
## Owns
|
## Actor and decision semantics
|
||||||
|
|
||||||
- approval requests
|
Selectors can target an account, group, role, function assignment, or any
|
||||||
- sign-off chains
|
authenticated account. They are evaluated against trusted principal claims.
|
||||||
- delegation and substitution facts
|
Delegated decisions are accepted only when the principal already carries the
|
||||||
- four-eyes constraints
|
matching acting-for account. The actual and represented account plus the
|
||||||
- escalation state
|
trusted delegation identifier are retained.
|
||||||
- signature references
|
|
||||||
|
|
||||||
## Does Not Own
|
Requests can prohibit requester self-approval and can require distinct actors
|
||||||
|
across steps. Each decision is append-only, reasoned, optimistic-concurrency
|
||||||
|
protected, and idempotent. Signature-required steps store a provider-owned
|
||||||
|
signature reference; Approvals does not implement document signing or key
|
||||||
|
custody. A fail-fast rejection ends the request. Due steps can enter an
|
||||||
|
explicit escalated state without silently changing their outcome.
|
||||||
|
|
||||||
- module-specific business decisions
|
## Recovery and scale-out
|
||||||
- workflow orchestration engine
|
|
||||||
- identity and RBAC primitives
|
|
||||||
|
|
||||||
## Integration Candidates
|
All API and worker nodes use the logically shared database. Back up and restore
|
||||||
|
these tables as one consistency unit:
|
||||||
|
|
||||||
- workflow
|
- `approval_request_revisions`
|
||||||
- audit
|
- `approval_decision_records`
|
||||||
- files
|
- `approval_lifecycle_events`
|
||||||
- notifications
|
- `approval_replays`
|
||||||
|
|
||||||
## Seed State
|
After restore, verify one current revision per tenant/request, contiguous event
|
||||||
|
sequences, unique actor decisions per step, decision receipt hashes, and exact
|
||||||
|
subject bindings. Consumers must re-run `check_approved`; they must not infer
|
||||||
|
approval from cached UI state. Before destructive retirement, snapshot the
|
||||||
|
database and reconcile every module object that retains an Approval reference.
|
||||||
|
|
||||||
The current repository state is intentionally small:
|
## Optional integrations
|
||||||
|
|
||||||
- module manifest and entry point
|
Workflow Engine may wait for completion and Notifications may announce an
|
||||||
- tenant-level permission definitions
|
assignment, due date, escalation, or outcome. Audit may retain additional
|
||||||
- manager and viewer role templates
|
cross-domain evidence. Policy may provide chain templates. These integrations
|
||||||
- documentation topic describing the module boundary
|
use capabilities and events; none reads Approval tables directly.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## First Implementation Slice
|
|
||||||
|
|
||||||
Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules.
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Approvals Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to the
|
||||||
|
Approvals-owned route without changing the append-only approval model or
|
||||||
|
importing optional sibling modules.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/approvals` list | Searchable work queue | Select request or create immutable chain | Shared loading, empty, error, permission, selection, and help states |
|
||||||
|
| Approval detail | Governed record detail | Inspect exact subject, chain, state, and evidence | Locale-aware history, status, provenance, explained lifecycle actions |
|
||||||
|
| Request dialog | Consequential definition editor | Freeze exact subject and approval chain | Guarded draft, field help, validation reasons, at least one step |
|
||||||
|
| Decision dialog | Governed decision | Append approval or rejection evidence | Required reason/signature, guarded draft, explicit confirmation action |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Creation freezes the subject identity and digest plus every ordered step,
|
||||||
|
selector, quorum, separation, and signature requirement.
|
||||||
|
- Approving or rejecting appends attributable evidence and cannot be edited.
|
||||||
|
- Only pending or escalated requests can be decided. Terminal requests remain
|
||||||
|
available for reconstruction.
|
||||||
|
- Missing create or decide permission is visible and points to the Access role
|
||||||
|
assignment destination and responsible administrator.
|
||||||
|
- Signature references are evidence pointers and never a cryptographic claim.
|
||||||
|
|
||||||
|
The module uses Core dialogs, controls, status, blockers, help, loading, empty,
|
||||||
|
error, and draft-guard contracts. Native selection buttons preserve keyboard
|
||||||
|
order; bounded list/detail viewports remain responsive. English and German
|
||||||
|
catalogues cover module-owned copy and dates follow the active platform locale.
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/approvals",
|
"name": "@govoplan/approvals",
|
||||||
"version": "0.1.7",
|
"version": "0.1.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Approvals platform module seed.",
|
"description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-approvals"
|
name = "govoplan-approvals"
|
||||||
version = "0.1.7"
|
version = "0.1.17"
|
||||||
description = "GovOPlaN Approvals platform module seed."
|
description = "Governed approval chains, decisions, delegation, and escalation 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.7",
|
"govoplan-core>=0.1.17",
|
||||||
"govoplan-access>=0.1.7",
|
"govoplan-access>=0.1.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from govoplan_approvals.backend.db.models import (
|
||||||
|
ApprovalDecisionRecord,
|
||||||
|
ApprovalLifecycleEvent,
|
||||||
|
ApprovalRequestRevision,
|
||||||
|
ApprovalReplay,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApprovalDecisionRecord",
|
||||||
|
"ApprovalLifecycleEvent",
|
||||||
|
"ApprovalReplay",
|
||||||
|
"ApprovalRequestRevision",
|
||||||
|
]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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 ApprovalRequestRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "approval_request_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_approval_request_current", "tenant_id", "request_id", "superseded_at"
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_approval_request_subject",
|
||||||
|
"tenant_id",
|
||||||
|
"subject_module",
|
||||||
|
"subject_type",
|
||||||
|
"subject_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)
|
||||||
|
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("approval_request_revisions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
current_step_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(120), nullable=True, index=True
|
||||||
|
)
|
||||||
|
subject_module: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
subject_version: Mapped[str | None] = mapped_column(
|
||||||
|
String(120), nullable=True, index=True
|
||||||
|
)
|
||||||
|
subject_digest: Mapped[str] = mapped_column(String(64), 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
|
||||||
|
)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalDecisionRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "approval_decision_records"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"step_key",
|
||||||
|
"effective_actor_id",
|
||||||
|
name="uq_approval_step_actor",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_approval_decision_replay",
|
||||||
|
),
|
||||||
|
Index("ix_approval_decision_history", "tenant_id", "request_id", "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)
|
||||||
|
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
request_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
step_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
outcome: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
reason: Mapped[str] = mapped_column(String(4000), nullable=False)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
effective_actor_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
authority_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
signature_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalLifecycleEvent(Base, TimestampMixin):
|
||||||
|
__tablename__ = "approval_lifecycle_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalReplay(Base, TimestampMixin):
|
||||||
|
__tablename__ = "approval_replays"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
operation: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTemplateRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "approval_template_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_approval_template_current", "tenant_id", "template_id", "superseded_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)
|
||||||
|
template_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("approval_template_revisions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
content_sha256: Mapped[str] = mapped_column(String(64), 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
|
||||||
|
)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApprovalDecisionRecord",
|
||||||
|
"ApprovalLifecycleEvent",
|
||||||
|
"ApprovalReplay",
|
||||||
|
"ApprovalRequestRevision",
|
||||||
|
"ApprovalTemplateRevision",
|
||||||
|
]
|
||||||
@@ -1,20 +1,45 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.approvals import CAPABILITY_APPROVAL_REQUESTS
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_approvals.backend.db import models as approval_models
|
||||||
|
from govoplan_approvals.backend.service import SqlApprovalRequests
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "approvals"
|
MODULE_ID = "approvals"
|
||||||
MODULE_NAME = "Approvals"
|
MODULE_NAME = "Approvals"
|
||||||
MODULE_VERSION = "0.1.7"
|
MODULE_VERSION = "0.1.17"
|
||||||
READ_SCOPE = "approvals:workspace:read"
|
READ_SCOPE = "approvals:workspace:read"
|
||||||
WRITE_SCOPE = "approvals:workspace:write"
|
WRITE_SCOPE = "approvals:workspace:write"
|
||||||
|
DECIDE_SCOPE = "approvals:workspace:decide"
|
||||||
ADMIN_SCOPE = "approvals:workspace:admin"
|
ADMIN_SCOPE = "approvals:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
|
||||||
"workflow",
|
|
||||||
"audit",
|
|
||||||
"files",
|
|
||||||
"notifications",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -23,7 +48,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
scope=scope,
|
scope=scope,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
category="Approvals",
|
category=MODULE_NAME,
|
||||||
level="tenant",
|
level="tenant",
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
resource=resource,
|
resource=resource,
|
||||||
@@ -31,56 +56,28 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
def _router(_context: ModuleContext):
|
||||||
_permission(READ_SCOPE, "View approvals workspace", "Read approvals records, configuration, and workflow context."),
|
from govoplan_approvals.backend.router import router
|
||||||
_permission(WRITE_SCOPE, "Manage approvals workspace", "Create and update approvals records and workflow state."),
|
|
||||||
_permission(ADMIN_SCOPE, "Administer approvals workspace", "Configure approvals policies, templates, and tenant-level administration."),
|
|
||||||
)
|
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
return router
|
||||||
RoleTemplate(
|
|
||||||
slug="approvals_manager",
|
|
||||||
name="Approvals manager",
|
|
||||||
description="Manage approvals records and workflow state.",
|
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
|
||||||
),
|
|
||||||
RoleTemplate(
|
|
||||||
slug="approvals_viewer",
|
|
||||||
name="Approvals viewer",
|
|
||||||
description="Read approvals records and workflow context.",
|
|
||||||
permissions=(READ_SCOPE,),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
DOCUMENTATION = (
|
|
||||||
DocumentationTopic(
|
def _requests(_context: ModuleContext) -> SqlApprovalRequests:
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
return SqlApprovalRequests()
|
||||||
title=f"{MODULE_NAME} module boundary",
|
|
||||||
summary="Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.",
|
|
||||||
body=(
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
current = session.query(approval_models.ApprovalRequestRevision).filter(
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
approval_models.ApprovalRequestRevision.superseded_at.is_(None),
|
||||||
),
|
|
||||||
layer="available",
|
|
||||||
documentation_types=("admin",),
|
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
|
||||||
order=100,
|
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
|
||||||
links=(
|
|
||||||
DocumentationLink(
|
|
||||||
label="Repository domain boundary",
|
|
||||||
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
|
|
||||||
kind="repository",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
metadata={
|
|
||||||
"seed": True,
|
|
||||||
"domain_objects": ['approval requests', 'sign-off chains', 'delegation and substitution facts', 'four-eyes constraints', 'escalation state', 'signature references'],
|
|
||||||
"first_slice": "Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules.",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"approval_requests": current.count(),
|
||||||
|
"approval_pending": current.filter(
|
||||||
|
approval_models.ApprovalRequestRevision.state.in_(("pending", "escalated"))
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
@@ -88,10 +85,278 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
permissions=PERMISSIONS,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
role_templates=ROLE_TEMPLATES,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
documentation=DOCUMENTATION,
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"),
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(
|
||||||
|
READ_SCOPE,
|
||||||
|
"View approval requests",
|
||||||
|
"Read approval chains, current gates, outcomes, and history.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Request approvals",
|
||||||
|
"Create immutable approval chains for exact subject revisions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
DECIDE_SCOPE,
|
||||||
|
"Decide approvals",
|
||||||
|
"Approve or reject eligible approval steps.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer approvals",
|
||||||
|
"Escalate due approvals and configure approval policies.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="approvals_manager",
|
||||||
|
name="Approvals manager",
|
||||||
|
description="Create and manage approval requests.",
|
||||||
|
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="approver",
|
||||||
|
name="Approver",
|
||||||
|
description="Read and decide eligible approval steps.",
|
||||||
|
permissions=(READ_SCOPE, DECIDE_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="approvals_admin",
|
||||||
|
name="Approvals administrator",
|
||||||
|
description="Administer approval policies and escalation.",
|
||||||
|
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/approvals",
|
||||||
|
label="Approvals",
|
||||||
|
icon="list-checks",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/approvals-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/approvals",
|
||||||
|
component="ApprovalsPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/approvals",
|
||||||
|
label="Approvals",
|
||||||
|
icon="list-checks",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=37,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="approvals.navigation",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="navigation",
|
||||||
|
label="Approvals navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="approvals.workspace",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Approval request workspace",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="approvals.admin.templates",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Approval templates",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests},
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_APPROVAL_REQUESTS: CapabilityDocumentation(
|
||||||
|
label="Governed approval requests",
|
||||||
|
summary="Freezes exact subject approval chains and resolves auditable sequential decisions.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
approval_models.ApprovalReplay,
|
||||||
|
approval_models.ApprovalLifecycleEvent,
|
||||||
|
approval_models.ApprovalDecisionRecord,
|
||||||
|
approval_models.ApprovalRequestRevision,
|
||||||
|
approval_models.ApprovalTemplateRevision,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement requires a verified snapshot and removes approval chains, decisions, signature references, and lifecycle evidence.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
approval_models.ApprovalRequestRevision,
|
||||||
|
approval_models.ApprovalDecisionRecord,
|
||||||
|
approval_models.ApprovalLifecycleEvent,
|
||||||
|
approval_models.ApprovalReplay,
|
||||||
|
approval_models.ApprovalTemplateRevision,
|
||||||
|
label=MODULE_NAME,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="approvals.module-boundary",
|
||||||
|
title="Governed approval chains",
|
||||||
|
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
|
||||||
|
body=(
|
||||||
|
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
|
||||||
|
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Approvals boundary and recovery",
|
||||||
|
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"approvals.navigation",
|
||||||
|
"approvals.workspace",
|
||||||
|
"approvals.admin.templates",
|
||||||
|
"approvals.state.permission-blocked",
|
||||||
|
"approvals.state.empty",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Approval lists and histories remain tenant-bound and permission-filtered.",
|
||||||
|
"Signature references identify evidence but do not expose private key material.",
|
||||||
|
"Decision history retains actor and reason as governed evidence.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="approvals.reference.fields-and-consequences",
|
||||||
|
title="Approval fields and consequences",
|
||||||
|
summary="Exact-subject identity, selector, separation-of-duty, signature, and decision consequences.",
|
||||||
|
body=(
|
||||||
|
"Subject module, type, identifier, version, and SHA-256 digest freeze the exact object revision being approved. "
|
||||||
|
"Ordered steps, actor selectors, required counts, requester separation, unique actors, and signature requirements "
|
||||||
|
"are copied into the immutable request and do not follow later template changes. Actor values are provider-neutral "
|
||||||
|
"identifiers interpreted through Access and IDM contracts. Approval or rejection appends a decision with actor, "
|
||||||
|
"reason, optional signature reference, and optimistic-concurrency revision. Completed, rejected, cancelled, and "
|
||||||
|
"expired requests remain evidence and cannot be decided again."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Approvals boundary and recovery",
|
||||||
|
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"approvals.field.subject-reference",
|
||||||
|
"approvals.field.subject-digest",
|
||||||
|
"approvals.field.actor-selector",
|
||||||
|
"approvals.field.separation-of-duties",
|
||||||
|
"approvals.field.signature-reference",
|
||||||
|
"approvals.action.create-request",
|
||||||
|
"approvals.action.decide-request",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"create_request": "Freezes an exact subject and immutable approval chain.",
|
||||||
|
"approve_step": "Appends an attributable decision and may advance or complete the chain.",
|
||||||
|
"reject_request": "Appends a rejection and completes the request according to its frozen policy.",
|
||||||
|
"retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="approvals.workflow.administer-templates",
|
||||||
|
title="Administer approval templates",
|
||||||
|
summary="Create reusable approval chains, publish immutable revisions, compare history, and escalate steps only after their configured due time.",
|
||||||
|
body=(
|
||||||
|
"Approval administrators manage templates under Admin > Tenant > Approval templates. A stable key identifies the template while every edit creates a new draft revision with its own content hash, actor, predecessor, and timestamp. Publishing creates another immutable revision that new requests can bind to exactly; existing requests never follow later template changes. "
|
||||||
|
"The history dialog compares any two tenant-visible revisions as deterministic JSON-pointer changes without hiding unchanged evidence. Request operators with approval administration permission see Escalate only for pending requests, and the action becomes available after the current step's due time. The backend rechecks the due time and optimistic-concurrency revision before recording the lifecycle transition."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "product_owner", "auditor"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
|
||||||
|
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
|
||||||
|
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
|
||||||
|
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"approvals.admin.templates",
|
||||||
|
"approvals.action.escalate-request",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"revise_template": "Supersedes the current template and creates a new draft revision.",
|
||||||
|
"publish_template": "Creates an immutable published revision available to new requests.",
|
||||||
|
"escalate_request": "Records that the current due step entered escalation without deciding it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="human_work_procedure",
|
||||||
|
kind="governance",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/APPROVALS_DOMAIN_BOUNDARY.md",
|
||||||
|
test_ref="tests/test_approvals.py",
|
||||||
|
known_limits=(
|
||||||
|
"Policy-authored template selection and cryptographic signature providers remain optional product depth; signature references are evidence pointers, not a cryptographic claim.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=("native_authoritative",),
|
||||||
|
owned_concepts=(
|
||||||
|
"approval request",
|
||||||
|
"approval chain",
|
||||||
|
"approval decision",
|
||||||
|
"approval escalation",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"workflow execution",
|
||||||
|
"identity",
|
||||||
|
"document signature",
|
||||||
|
"module business outcome",
|
||||||
|
),
|
||||||
|
migration_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
|
||||||
|
recovery_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
|
||||||
|
security_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
|
||||||
|
operations_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Approvals migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Approvals migration revisions."""
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
"""v0.1.14 Approvals runtime.
|
||||||
|
|
||||||
|
Revision ID: a91c4e72b5d8
|
||||||
|
Revises: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a91c4e72b5d8"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"approval_request_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("request_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("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("current_step_key", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("subject_module", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("subject_version", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("subject_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["approval_request_revisions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_approval_request_revisions_previous_revision_id_approval_request_revisions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_request_revisions")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"previous_revision_id",
|
||||||
|
"state",
|
||||||
|
"current_step_key",
|
||||||
|
"subject_module",
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
"subject_version",
|
||||||
|
"subject_digest",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"actor_id",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_approval_request_revisions_{column}"),
|
||||||
|
"approval_request_revisions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_approval_request_current",
|
||||||
|
"approval_request_revisions",
|
||||||
|
["tenant_id", "request_id", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_approval_request_subject",
|
||||||
|
"approval_request_revisions",
|
||||||
|
["tenant_id", "subject_module", "subject_type", "subject_id", "state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"approval_decision_records",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("request_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("request_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("step_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("outcome", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("reason", sa.String(length=4000), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("effective_actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("delegation_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("authority_provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("signature_ref", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("receipt_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_decision_records")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"step_key",
|
||||||
|
"effective_actor_id",
|
||||||
|
name="uq_approval_step_actor",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_approval_decision_replay",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
"step_key",
|
||||||
|
"outcome",
|
||||||
|
"actor_id",
|
||||||
|
"effective_actor_id",
|
||||||
|
"recorded_at",
|
||||||
|
"receipt_sha256",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_approval_decision_records_{column}"),
|
||||||
|
"approval_decision_records",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_approval_decision_history",
|
||||||
|
"approval_decision_records",
|
||||||
|
["tenant_id", "request_id", "recorded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"approval_lifecycle_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("request_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||||
|
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_approval_lifecycle_events")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "request_id", "event_type", "recorded_at"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_approval_lifecycle_events_{column}"),
|
||||||
|
"approval_lifecycle_events",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"approval_replays",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("operation", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("response", 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_approval_replays")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "operation"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_approval_replays_{column}"),
|
||||||
|
"approval_replays",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"approval_template_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("content_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["previous_revision_id"],
|
||||||
|
["approval_template_revisions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_approval_template_revisions_previous_revision_id_approval_template_revisions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_template_revisions")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"template_id",
|
||||||
|
"key",
|
||||||
|
"previous_revision_id",
|
||||||
|
"state",
|
||||||
|
"content_sha256",
|
||||||
|
"recorded_at",
|
||||||
|
"superseded_at",
|
||||||
|
"actor_id",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_approval_template_revisions_{column}"),
|
||||||
|
"approval_template_revisions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_approval_template_current",
|
||||||
|
"approval_template_revisions",
|
||||||
|
["tenant_id", "template_id", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("approval_template_revisions")
|
||||||
|
op.drop_table("approval_replays")
|
||||||
|
op.drop_table("approval_lifecycle_events")
|
||||||
|
op.drop_table("approval_decision_records")
|
||||||
|
op.drop_table("approval_request_revisions")
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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.db.session import get_session
|
||||||
|
from govoplan_approvals.backend.schemas import (
|
||||||
|
ApprovalCreateRequest,
|
||||||
|
ApprovalDecisionInput,
|
||||||
|
ApprovalListResponse,
|
||||||
|
ApprovalTemplateCreateRequest,
|
||||||
|
ApprovalTemplateReviseRequest,
|
||||||
|
ApprovalTransitionInput,
|
||||||
|
)
|
||||||
|
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/approvals", tags=["approvals"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, LookupError):
|
||||||
|
return HTTPException(status_code=404, detail=str(exc))
|
||||||
|
text = str(exc)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=409
|
||||||
|
if "conflict" in text.lower() or "idempotency" in text.lower()
|
||||||
|
else 400,
|
||||||
|
detail=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApprovalListResponse)
|
||||||
|
def api_list_requests(
|
||||||
|
request_state: str | None = Query(default=None, alias="state"),
|
||||||
|
subject_module: str | None = None,
|
||||||
|
subject_id: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ApprovalListResponse:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
return ApprovalListResponse(
|
||||||
|
requests=list(
|
||||||
|
SqlApprovalRequests().list_requests(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
state=request_state,
|
||||||
|
subject_module=subject_module,
|
||||||
|
subject_id=subject_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=list[dict[str, Any]])
|
||||||
|
def api_list_templates(
|
||||||
|
template_state: str | None = Query(default=None, alias="state"),
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
return [
|
||||||
|
dict(item)
|
||||||
|
for item in SqlApprovalRequests().list_templates(
|
||||||
|
session, principal, state=template_state, limit=limit
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/templates", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
def api_create_template(
|
||||||
|
payload: ApprovalTemplateCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
|
||||||
|
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
result = SqlApprovalRequests().create_template(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
command=payload.template.to_command(),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().get_template(
|
||||||
|
session, principal, template_id=result.id
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/templates/{template_id}", response_model=dict[str, Any])
|
||||||
|
def api_revise_template(
|
||||||
|
template_id: str,
|
||||||
|
payload: ApprovalTemplateReviseRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
|
||||||
|
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
result = SqlApprovalRequests().revise_template(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
template_id=template_id,
|
||||||
|
command=payload.template.to_command(),
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().get_template(
|
||||||
|
session, principal, template_id=result.id
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/{template_id}/publish", response_model=dict[str, Any])
|
||||||
|
def api_publish_template(
|
||||||
|
template_id: str,
|
||||||
|
payload: ApprovalTransitionInput,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
|
||||||
|
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
result = SqlApprovalRequests().publish_template(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
template_id=template_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().get_template(
|
||||||
|
session, principal, template_id=result.id
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{template_id}", response_model=dict[str, Any])
|
||||||
|
def api_get_template(
|
||||||
|
template_id: str,
|
||||||
|
revision: int | None = Query(default=None, ge=1),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = SqlApprovalRequests().get_template(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
template_id=template_id,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval template not found")
|
||||||
|
return dict(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/templates/{template_id}/history",
|
||||||
|
response_model=list[dict[str, Any]],
|
||||||
|
)
|
||||||
|
def api_get_template_history(
|
||||||
|
template_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
dict(item)
|
||||||
|
for item in SqlApprovalRequests().template_history(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
template_id=template_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/templates/{template_id}/compare",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
)
|
||||||
|
def api_compare_template_revisions(
|
||||||
|
template_id: str,
|
||||||
|
from_revision: int = Query(ge=1),
|
||||||
|
to_revision: int = Query(ge=1),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().compare_template_revisions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
template_id=template_id,
|
||||||
|
from_revision=from_revision,
|
||||||
|
to_revision=to_revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}", response_model=dict[str, Any])
|
||||||
|
def api_get_request(
|
||||||
|
request_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = SqlApprovalRequests().get_request(session, principal, request_id=request_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
return dict(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/history", response_model=list[dict[str, Any]])
|
||||||
|
def api_get_history(
|
||||||
|
request_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||||
|
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
if (
|
||||||
|
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
return [
|
||||||
|
dict(item)
|
||||||
|
for item in SqlApprovalRequests().history(
|
||||||
|
session, principal, request_id=request_id
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
|
||||||
|
def api_create_request(
|
||||||
|
payload: ApprovalCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import WRITE_SCOPE
|
||||||
|
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
result = SqlApprovalRequests().create_request(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
command=payload.request.to_command(),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().get_request(session, principal, request_id=result.id)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/decisions", response_model=dict[str, Any])
|
||||||
|
def api_decide(
|
||||||
|
request_id: str,
|
||||||
|
payload: ApprovalDecisionInput,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import DECIDE_SCOPE
|
||||||
|
|
||||||
|
_require(principal, DECIDE_SCOPE)
|
||||||
|
try:
|
||||||
|
receipt = SqlApprovalRequests().decide(
|
||||||
|
session, principal, request_id=request_id, command=payload.to_command()
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return {
|
||||||
|
"receipt": {
|
||||||
|
"request_id": receipt.request_id,
|
||||||
|
"revision": receipt.revision,
|
||||||
|
"step_key": receipt.step_key,
|
||||||
|
"outcome": receipt.outcome,
|
||||||
|
"actor_id": receipt.actor_id,
|
||||||
|
"recorded_at": receipt.recorded_at,
|
||||||
|
"receipt_sha256": receipt.receipt_sha256,
|
||||||
|
"replayed": receipt.replayed,
|
||||||
|
},
|
||||||
|
"request": dict(
|
||||||
|
SqlApprovalRequests().get_request(
|
||||||
|
session, principal, request_id=request_id
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/escalate", response_model=dict[str, Any])
|
||||||
|
def api_escalate(
|
||||||
|
request_id: str,
|
||||||
|
payload: ApprovalTransitionInput,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
|
||||||
|
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
SqlApprovalRequests().escalate_due(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request_id=request_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return dict(
|
||||||
|
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
except (ApprovalStoreError, LookupError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from govoplan_core.core.approvals import (
|
||||||
|
ApprovalActorSelector,
|
||||||
|
ApprovalDecisionCommand,
|
||||||
|
ApprovalRequestCreateCommand,
|
||||||
|
ApprovalStepDefinition,
|
||||||
|
ApprovalTemplateCreateCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalSelectorInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
|
||||||
|
value: str = Field(min_length=1, max_length=255)
|
||||||
|
label: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStepInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
key: str = Field(min_length=1, max_length=120)
|
||||||
|
label: str = Field(min_length=1, max_length=255)
|
||||||
|
selectors: list[ApprovalSelectorInput] = Field(min_length=1, max_length=500)
|
||||||
|
required_approvals: int = Field(default=1, ge=1, le=500)
|
||||||
|
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
|
||||||
|
due_at: datetime | None = None
|
||||||
|
signature_required: bool = False
|
||||||
|
forbidden_evidence_roles: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_definition(self) -> ApprovalStepDefinition:
|
||||||
|
return ApprovalStepDefinition(
|
||||||
|
key=self.key,
|
||||||
|
label=self.label,
|
||||||
|
selectors=tuple(
|
||||||
|
ApprovalActorSelector(item.kind, item.value, item.label)
|
||||||
|
for item in self.selectors
|
||||||
|
),
|
||||||
|
required_approvals=self.required_approvals,
|
||||||
|
rejection_policy=self.rejection_policy,
|
||||||
|
due_at=self.due_at,
|
||||||
|
signature_required=self.signature_required,
|
||||||
|
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
|
||||||
|
metadata=self.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequestInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=10_000)
|
||||||
|
subject_module: str = Field(min_length=1, max_length=120)
|
||||||
|
subject_type: str = Field(min_length=1, max_length=120)
|
||||||
|
subject_id: str = Field(min_length=1, max_length=255)
|
||||||
|
subject_version: str | None = Field(default=None, max_length=120)
|
||||||
|
subject_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
steps: list[ApprovalStepInput] = Field(default_factory=list, max_length=100)
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
policy_refs: list[str] = Field(default_factory=list, max_length=500)
|
||||||
|
evidence_actors: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
template_id: str | None = Field(default=None, max_length=36)
|
||||||
|
template_revision: int | None = Field(default=None, ge=1)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_command(self) -> ApprovalRequestCreateCommand:
|
||||||
|
return ApprovalRequestCreateCommand(
|
||||||
|
title=self.title,
|
||||||
|
description=self.description,
|
||||||
|
subject_module=self.subject_module,
|
||||||
|
subject_type=self.subject_type,
|
||||||
|
subject_id=self.subject_id,
|
||||||
|
subject_version=self.subject_version,
|
||||||
|
subject_digest=self.subject_digest,
|
||||||
|
steps=tuple(step.to_definition() for step in self.steps),
|
||||||
|
separation_of_duties=self.separation_of_duties,
|
||||||
|
unique_actors_across_steps=self.unique_actors_across_steps,
|
||||||
|
expires_at=self.expires_at,
|
||||||
|
policy_refs=tuple(self.policy_refs),
|
||||||
|
evidence_actors={
|
||||||
|
key: tuple(values) for key, values in self.evidence_actors.items()
|
||||||
|
},
|
||||||
|
template_id=self.template_id,
|
||||||
|
template_revision=self.template_revision,
|
||||||
|
metadata=self.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTemplateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
key: str = Field(min_length=1, max_length=120)
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=10_000)
|
||||||
|
steps: list[ApprovalStepInput] = Field(min_length=1, max_length=100)
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_command(self) -> ApprovalTemplateCreateCommand:
|
||||||
|
return ApprovalTemplateCreateCommand(
|
||||||
|
key=self.key,
|
||||||
|
title=self.title,
|
||||||
|
description=self.description,
|
||||||
|
steps=tuple(step.to_definition() for step in self.steps),
|
||||||
|
separation_of_duties=self.separation_of_duties,
|
||||||
|
unique_actors_across_steps=self.unique_actors_across_steps,
|
||||||
|
metadata=self.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTemplateCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
template: ApprovalTemplateInput
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTemplateReviseRequest(ApprovalTemplateCreateRequest):
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
request: ApprovalRequestInput
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalDecisionInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
outcome: Literal["approved", "rejected"]
|
||||||
|
reason: str = Field(min_length=1, max_length=4000)
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=160)
|
||||||
|
delegated_for_account_id: str | None = Field(default=None, max_length=255)
|
||||||
|
signature_ref: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_command(self) -> ApprovalDecisionCommand:
|
||||||
|
return ApprovalDecisionCommand(
|
||||||
|
outcome=self.outcome,
|
||||||
|
reason=self.reason,
|
||||||
|
expected_revision=self.expected_revision,
|
||||||
|
idempotency_key=self.idempotency_key,
|
||||||
|
delegated_for_account_id=self.delegated_for_account_id,
|
||||||
|
signature_ref=self.signature_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTransitionInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalListResponse(BaseModel):
|
||||||
|
requests: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApprovalCreateRequest",
|
||||||
|
"ApprovalDecisionInput",
|
||||||
|
"ApprovalListResponse",
|
||||||
|
"ApprovalTransitionInput",
|
||||||
|
"ApprovalTemplateCreateRequest",
|
||||||
|
"ApprovalTemplateReviseRequest",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.approvals import (
|
||||||
|
ApprovalActorSelector,
|
||||||
|
ApprovalDecisionCommand,
|
||||||
|
ApprovalRequestCreateCommand,
|
||||||
|
ApprovalStepDefinition,
|
||||||
|
ApprovalTemplateCreateCommand,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
|
||||||
|
|
||||||
|
|
||||||
|
DIGEST = "a" * 64
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
group_ids: tuple[str, ...] = ()
|
||||||
|
role_ids: tuple[str, ...] = ()
|
||||||
|
function_assignment_ids: tuple[str, ...] = ()
|
||||||
|
acting_for_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def request_command() -> ApprovalRequestCreateCommand:
|
||||||
|
return ApprovalRequestCreateCommand(
|
||||||
|
title="Approve Campaign delivery",
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_version",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="version-7",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
key="review",
|
||||||
|
label="Review",
|
||||||
|
selectors=(ApprovalActorSelector("group", "reviewers"),),
|
||||||
|
),
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
key="release",
|
||||||
|
label="Release",
|
||||||
|
selectors=(ApprovalActorSelector("role", "senders"),),
|
||||||
|
signature_required=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
separation_of_duties=True,
|
||||||
|
unique_actors_across_steps=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRuntimeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.service = SqlApprovalRequests()
|
||||||
|
self.requester = Principal("tenant-1", "requester")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
Base.metadata.drop_all(self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_sequential_chain_exact_subject_and_signature(self) -> None:
|
||||||
|
with self.Session() as session:
|
||||||
|
created = self.service.create_request(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=request_command(),
|
||||||
|
idempotency_key="create-1",
|
||||||
|
)
|
||||||
|
review = self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "reviewer", group_ids=("reviewers",)),
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand("approved", "Reviewed.", 1, "review-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(2, review.revision)
|
||||||
|
current = self.service.get_request(
|
||||||
|
session, self.requester, request_id=created.id
|
||||||
|
)
|
||||||
|
self.assertEqual("release", current["current_step_key"])
|
||||||
|
with self.assertRaisesRegex(ApprovalStoreError, "signature"):
|
||||||
|
self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "sender", role_ids=("senders",)),
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"approved", "Release.", 2, "release-no-signature"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
released = self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "sender", role_ids=("senders",)),
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"approved",
|
||||||
|
"Release.",
|
||||||
|
2,
|
||||||
|
"release-1",
|
||||||
|
signature_ref={"provider": "signatures", "id": "sig-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(3, released.revision)
|
||||||
|
check = self.service.check_approved(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
request_id=created.id,
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_version",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="version-7",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
)
|
||||||
|
self.assertTrue(check.approved)
|
||||||
|
with self.assertRaisesRegex(ApprovalStoreError, "exact requested subject"):
|
||||||
|
self.service.check_approved(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
request_id=created.id,
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_version",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="version-8",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_separation_of_duties_rejection_replay_and_tenant_isolation(self) -> None:
|
||||||
|
direct = ApprovalRequestCreateCommand(
|
||||||
|
title="Direct review",
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_version",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="version-7",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"review", "Review", (ApprovalActorSelector("account", "requester"),)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
created = self.service.create_request(
|
||||||
|
session, self.requester, command=direct, idempotency_key="create-direct"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ApprovalStoreError, "separation of duties"):
|
||||||
|
self.service.decide(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand("approved", "Self.", 1, "self-1"),
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.service.get_request(
|
||||||
|
session, Principal("tenant-2", "requester"), request_id=created.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected_command = ApprovalRequestCreateCommand(
|
||||||
|
title="Review",
|
||||||
|
subject_module="cases",
|
||||||
|
subject_type="case",
|
||||||
|
subject_id="case-1",
|
||||||
|
subject_version="1",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"review",
|
||||||
|
"Review",
|
||||||
|
(ApprovalActorSelector("account", "reviewer"),),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rejected = self.service.create_request(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=rejected_command,
|
||||||
|
idempotency_key="create-reject",
|
||||||
|
)
|
||||||
|
receipt = self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "reviewer"),
|
||||||
|
request_id=rejected.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"rejected", "Insufficient evidence.", 1, "reject-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
replay = self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "reviewer"),
|
||||||
|
request_id=rejected.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"rejected", "Insufficient evidence.", 1, "reject-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(receipt.receipt_sha256, replay.receipt_sha256)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(
|
||||||
|
"rejected",
|
||||||
|
self.service.get_request(
|
||||||
|
session, self.requester, request_id=rejected.id
|
||||||
|
)["state"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_due_step_escalates_explicitly(self) -> None:
|
||||||
|
command = ApprovalRequestCreateCommand(
|
||||||
|
title="Due review",
|
||||||
|
subject_module="files",
|
||||||
|
subject_type="file",
|
||||||
|
subject_id="file-1",
|
||||||
|
subject_version="1",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"review",
|
||||||
|
"Review",
|
||||||
|
(ApprovalActorSelector("account", "reviewer"),),
|
||||||
|
due_at=datetime.now(UTC) - timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
created = self.service.create_request(
|
||||||
|
session, self.requester, command=command, idempotency_key="create-due"
|
||||||
|
)
|
||||||
|
escalated = self.service.escalate_due(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
request_id=created.id,
|
||||||
|
expected_revision=1,
|
||||||
|
idempotency_key="escalate-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("escalated", escalated.state)
|
||||||
|
|
||||||
|
def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
|
||||||
|
template_command = ApprovalTemplateCreateCommand(
|
||||||
|
key="campaign-release",
|
||||||
|
title="Campaign release",
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"release",
|
||||||
|
"Release",
|
||||||
|
(ApprovalActorSelector("role", "senders"),),
|
||||||
|
forbidden_evidence_roles=("builder",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
draft = self.service.create_template(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=template_command,
|
||||||
|
idempotency_key="template-1",
|
||||||
|
)
|
||||||
|
published = self.service.publish_template(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
template_id=draft.id,
|
||||||
|
expected_revision=1,
|
||||||
|
idempotency_key="publish-1",
|
||||||
|
)
|
||||||
|
request = ApprovalRequestCreateCommand(
|
||||||
|
title="Approve exact execution",
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_execution",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="build-7",
|
||||||
|
subject_digest=DIGEST,
|
||||||
|
steps=(),
|
||||||
|
evidence_actors={"builder": ("builder",)},
|
||||||
|
template_id=published.id,
|
||||||
|
template_revision=published.revision,
|
||||||
|
)
|
||||||
|
created = self.service.create_request(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=request,
|
||||||
|
idempotency_key="templated-request-1",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ApprovalStoreError, "builder actor"):
|
||||||
|
self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "builder", role_ids=("senders",)),
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"approved", "Built and release attempted.", 1, "builder-release"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
receipt = self.service.decide(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "sender", role_ids=("senders",)),
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"approved", "Independent release.", 1, "sender-release"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"role", receipt.authority_provenance["matched_selector"]["kind"]
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ApprovalStoreError, "content digest"):
|
||||||
|
self.service.check_approved(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
request_id=created.id,
|
||||||
|
subject_module="campaigns",
|
||||||
|
subject_type="campaign_execution",
|
||||||
|
subject_id="campaign-1",
|
||||||
|
subject_version="build-7",
|
||||||
|
subject_digest="b" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_template_history_and_structural_compare_are_tenant_bound(self) -> None:
|
||||||
|
original = ApprovalTemplateCreateCommand(
|
||||||
|
key="monthly-release",
|
||||||
|
title="Monthly release",
|
||||||
|
description="Initial process",
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"review",
|
||||||
|
"Review",
|
||||||
|
(ApprovalActorSelector("role", "reviewers"),),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
revised_command = ApprovalTemplateCreateCommand(
|
||||||
|
key="monthly-release",
|
||||||
|
title="Monthly release approval",
|
||||||
|
description="Initial process",
|
||||||
|
steps=(
|
||||||
|
ApprovalStepDefinition(
|
||||||
|
"review",
|
||||||
|
"Independent review",
|
||||||
|
(ApprovalActorSelector("role", "reviewers"),),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
created = self.service.create_template(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=original,
|
||||||
|
idempotency_key="history-template-1",
|
||||||
|
)
|
||||||
|
revised = self.service.revise_template(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
template_id=created.id,
|
||||||
|
command=revised_command,
|
||||||
|
expected_revision=1,
|
||||||
|
idempotency_key="history-template-2",
|
||||||
|
)
|
||||||
|
self.assertEqual(2, revised.revision)
|
||||||
|
|
||||||
|
history = self.service.template_history(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
template_id=created.id,
|
||||||
|
)
|
||||||
|
self.assertEqual([2, 1], [item["revision"] for item in history])
|
||||||
|
self.assertIsNotNone(history[1]["superseded_at"])
|
||||||
|
self.assertEqual("requester", history[0]["actor_id"])
|
||||||
|
|
||||||
|
comparison = self.service.compare_template_revisions(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
template_id=created.id,
|
||||||
|
from_revision=1,
|
||||||
|
to_revision=2,
|
||||||
|
)
|
||||||
|
changes = {item["path"]: item for item in comparison["changes"]}
|
||||||
|
self.assertEqual(
|
||||||
|
"Monthly release",
|
||||||
|
changes["/title"]["before"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"Independent review",
|
||||||
|
changes["/steps/0/label"]["after"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(LookupError):
|
||||||
|
self.service.template_history(
|
||||||
|
session,
|
||||||
|
Principal("tenant-2", "other"),
|
||||||
|
template_id=created.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_approvals.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual({"/approvals"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"approvals.navigation",
|
||||||
|
"approvals.workspace",
|
||||||
|
"approvals.admin.templates",
|
||||||
|
},
|
||||||
|
{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["approvals.module-boundary"]
|
||||||
|
reference = topics["approvals.reference.fields-and-consequences"]
|
||||||
|
templates = topics["approvals.workflow.administer-templates"]
|
||||||
|
self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
|
||||||
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
|
self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("create_request", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("reject_request", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+20
-8
@@ -2,22 +2,34 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_approvals.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
from govoplan_approvals.backend.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
DECIDE_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_runtime_contract(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "approvals")
|
self.assertEqual(manifest.id, "approvals")
|
||||||
self.assertEqual(manifest.name, "Approvals")
|
self.assertEqual(manifest.name, "Approvals")
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(manifest.dependencies, ("access",))
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
self.assertEqual(
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"approvals_manager", "approvals_viewer"})
|
{permission.scope for permission in manifest.permissions},
|
||||||
|
{READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE, ADMIN_SCOPE},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{role.slug for role in manifest.role_templates},
|
||||||
|
{"approvals_manager", "approver", "approvals_admin"},
|
||||||
|
)
|
||||||
self.assertTrue(manifest.documentation)
|
self.assertTrue(manifest.documentation)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNone(manifest.frontend)
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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_approvals.backend.manifest import get_manifest
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalsMigrationTests(unittest.TestCase):
|
||||||
|
def test_fresh_migration_creates_approval_runtime_tables(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-approvals-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'approvals.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("approvals",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
tables = set(inspect(engine).get_table_names())
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"approval_template_revisions",
|
||||||
|
"approval_request_revisions",
|
||||||
|
"approval_decision_records",
|
||||||
|
"approval_lifecycle_events",
|
||||||
|
"approval_replays",
|
||||||
|
}.issubset(tables)
|
||||||
|
)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"a91c4e72b5d8",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/approvals-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/approvals.css": "./src/styles/approvals.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.17",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type ApprovalSelector = { kind: "account" | "group" | "role" | "function_assignment" | "any_account"; value: string; label?: string | null };
|
||||||
|
export type ApprovalStep = { key: string; label: string; selectors: ApprovalSelector[]; required_approvals: number; rejection_policy: "fail_fast" | "collect"; due_at?: string | null; signature_required: boolean; forbidden_evidence_roles: string[]; metadata: Record<string, unknown> };
|
||||||
|
export type ApprovalRequest = {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
state: "pending" | "escalated" | "approved" | "rejected" | "cancelled" | "expired";
|
||||||
|
current_step_key?: string | null;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
subject_module: string;
|
||||||
|
subject_type: string;
|
||||||
|
subject_id: string;
|
||||||
|
subject_version?: string | null;
|
||||||
|
subject_digest: string;
|
||||||
|
steps: ApprovalStep[];
|
||||||
|
current_step_index: number;
|
||||||
|
separation_of_duties: boolean;
|
||||||
|
unique_actors_across_steps: boolean;
|
||||||
|
expires_at?: string | null;
|
||||||
|
policy_refs: string[];
|
||||||
|
evidence_actors: Record<string, string[]>;
|
||||||
|
template_id?: string | null;
|
||||||
|
template_revision?: number | null;
|
||||||
|
requested_by?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type ApprovalDraft = Omit<ApprovalRequest, "id" | "revision" | "state" | "current_step_key" | "current_step_index" | "requested_by" | "completed_at">;
|
||||||
|
export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
|
||||||
|
export type ApprovalTemplate = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
revision: number;
|
||||||
|
state: "draft" | "published";
|
||||||
|
content_sha256: string;
|
||||||
|
recorded_at: string;
|
||||||
|
superseded_at?: string | null;
|
||||||
|
previous_revision_id?: string | null;
|
||||||
|
actor_id?: string | null;
|
||||||
|
steps: ApprovalStep[];
|
||||||
|
separation_of_duties: boolean;
|
||||||
|
unique_actors_across_steps: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type ApprovalTemplateDraft = Pick<ApprovalTemplate, "key" | "title" | "description" | "steps" | "separation_of_duties" | "unique_actors_across_steps" | "metadata">;
|
||||||
|
export type ApprovalTemplateChange = { path: string; change: "added" | "removed" | "changed"; before: unknown; after: unknown };
|
||||||
|
export type ApprovalTemplateComparison = {
|
||||||
|
template_id: string;
|
||||||
|
from_revision: ApprovalTemplate;
|
||||||
|
to_revision: ApprovalTemplate;
|
||||||
|
changes: ApprovalTemplateChange[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listApprovals(settings: ApiSettings, signal?: AbortSignal): Promise<{ requests: ApprovalRequest[] }> {
|
||||||
|
return apiFetch(settings, apiPath("/api/v1/approvals", { limit: 200 }), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApproval(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalRequest> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvalHistory(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalEvent[]> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}/history`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApproval(settings: ApiSettings, request: ApprovalDraft): Promise<ApprovalRequest> {
|
||||||
|
return apiFetch(settings, "/api/v1/approvals", { method: "POST", body: JSON.stringify({ request, idempotency_key: crypto.randomUUID() }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideApproval(settings: ApiSettings, request: ApprovalRequest, outcome: "approved" | "rejected", reason: string, signatureRef?: Record<string, unknown>): Promise<{ request: ApprovalRequest }> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/decisions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ outcome, reason, expected_revision: request.revision, idempotency_key: crypto.randomUUID(), signature_ref: signatureRef ?? null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escalateApproval(settings: ApiSettings, request: ApprovalRequest): Promise<ApprovalRequest> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/escalate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expected_revision: request.revision, idempotency_key: crypto.randomUUID() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApprovalTemplates(settings: ApiSettings, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
|
||||||
|
return apiFetch(settings, apiPath("/api/v1/approvals/templates", { limit: 200 }), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApprovalTemplate(settings: ApiSettings, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
|
||||||
|
return apiFetch(settings, "/api/v1/approvals/templates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ template, idempotency_key: crypto.randomUUID() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviseApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ template, expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate): Promise<ApprovalTemplate> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvalTemplateHistory(settings: ApiSettings, templateId: string, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
|
||||||
|
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(templateId)}/history`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareApprovalTemplateRevisions(settings: ApiSettings, templateId: string, fromRevision: number, toRevision: number, signal?: AbortSignal): Promise<ApprovalTemplateComparison> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/approvals/templates/${encodeURIComponent(templateId)}/compare`, {
|
||||||
|
from_revision: fromRevision,
|
||||||
|
to_revision: toRevision
|
||||||
|
}), { signal });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { createApproval, type ApprovalDraft, type ApprovalRequest } from "../../api/approvals";
|
||||||
|
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
|
||||||
|
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
|
||||||
|
const [baseline] = useState<ApprovalDraft>(() => initialDraft());
|
||||||
|
const [draft, setDraft] = useState<ApprovalDraft>(baseline);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]);
|
||||||
|
const dirty = draftKey(draft) !== draftKey(baseline);
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
onSaved(await createApproval(settings, draft));
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => setDraft(baseline),
|
||||||
|
title: "i18n:govoplan-approvals.unsaved_title",
|
||||||
|
message: "i18n:govoplan-approvals.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dialog open title="New Approval request" onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
|
||||||
|
<div className="approval-editor">
|
||||||
|
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<div className="approval-editor-grid">
|
||||||
|
<FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject ID" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject version" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject SHA-256" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
|
||||||
|
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
|
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
|
||||||
|
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
|
||||||
|
</div>
|
||||||
|
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDraft(): ApprovalDraft {
|
||||||
|
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyApprovalStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(draft: ApprovalDraft): string {
|
||||||
|
return JSON.stringify(draft);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DateTimeField,
|
||||||
|
FormField,
|
||||||
|
IconButton,
|
||||||
|
ToggleSwitch
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import type { ApprovalSelector, ApprovalStep } from "../../api/approvals";
|
||||||
|
import { APPROVALS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
export default function ApprovalStepsEditor({
|
||||||
|
steps,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
steps: ApprovalStep[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (steps: ApprovalStep[]) => void;
|
||||||
|
}) {
|
||||||
|
function patchStep(index: number, patch: Partial<ApprovalStep>) {
|
||||||
|
onChange(steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveStep(index: number, offset: -1 | 1) {
|
||||||
|
const target = index + offset;
|
||||||
|
if (target < 0 || target >= steps.length) return;
|
||||||
|
const next = [...steps];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSelector(stepIndex: number, selectorIndex: number, patch: Partial<ApprovalSelector>) {
|
||||||
|
const step = steps[stepIndex];
|
||||||
|
patchStep(stepIndex, {
|
||||||
|
selectors: step.selectors.map((selector, index) => index === selectorIndex ? { ...selector, ...patch } : selector)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="approval-editor-heading">
|
||||||
|
<h3>Steps</h3>
|
||||||
|
<Button disabled={disabled} disabledReason={disabled ? APPROVALS_I18N.busy : undefined} onClick={() => onChange([...steps, emptyApprovalStep(steps.length + 1)])}><Plus size={16} aria-hidden="true" />Add step</Button>
|
||||||
|
</div>
|
||||||
|
<div className="approval-step-list">
|
||||||
|
{steps.map((step, stepIndex) => <section className="approval-step-editor" key={`${stepIndex}:${step.key}`}>
|
||||||
|
<div className="approval-step-heading">
|
||||||
|
<strong>{step.label || `Step ${stepIndex + 1}`}</strong>
|
||||||
|
<div>
|
||||||
|
<IconButton label={`Move ${step.label || "step"} up`} icon={<ArrowUp />} disabled={disabled || stepIndex === 0} onClick={() => moveStep(stepIndex, -1)} />
|
||||||
|
<IconButton label={`Move ${step.label || "step"} down`} icon={<ArrowDown />} disabled={disabled || stepIndex === steps.length - 1} onClick={() => moveStep(stepIndex, 1)} />
|
||||||
|
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 />} variant="danger" disabled={disabled || steps.length === 1} disabledReason={steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => onChange(steps.filter((_, index) => index !== stepIndex))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="approval-step-fields">
|
||||||
|
<FormField label="Key"><input value={step.key} disabled={disabled} onChange={(event) => patchStep(stepIndex, { key: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Label"><input value={step.label} disabled={disabled} onChange={(event) => patchStep(stepIndex, { label: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Required approvals"><input type="number" min={1} max={500} value={step.required_approvals} disabled={disabled} onChange={(event) => patchStep(stepIndex, { required_approvals: Number(event.target.value) })} /></FormField>
|
||||||
|
<FormField label="Rejection policy"><select value={step.rejection_policy} disabled={disabled} onChange={(event) => patchStep(stepIndex, { rejection_policy: event.target.value as ApprovalStep["rejection_policy"] })}><option value="fail_fast">Fail immediately</option><option value="collect">Collect all decisions</option></select></FormField>
|
||||||
|
<FormField label="Escalation due"><DateTimeField value={step.due_at ?? ""} disabled={disabled} onChange={(value) => patchStep(stepIndex, { due_at: value || null })} /></FormField>
|
||||||
|
<FormField label="Forbidden evidence roles"><input value={step.forbidden_evidence_roles.join(", ")} disabled={disabled} onChange={(event) => patchStep(stepIndex, { forbidden_evidence_roles: commaList(event.target.value) })} /></FormField>
|
||||||
|
<ToggleSwitch label="Signature required" checked={step.signature_required} disabled={disabled} onChange={(value) => patchStep(stepIndex, { signature_required: value })} />
|
||||||
|
</div>
|
||||||
|
<div className="approval-selector-heading"><span>Eligible actors</span><Button disabled={disabled} onClick={() => patchStep(stepIndex, { selectors: [...step.selectors, emptySelector()] })}><Plus size={16} aria-hidden="true" />Add actor selector</Button></div>
|
||||||
|
<div className="approval-selector-list">
|
||||||
|
{step.selectors.map((selector, selectorIndex) => <div key={selectorIndex}>
|
||||||
|
<FormField label="Actor type"><select value={selector.kind} disabled={disabled} onChange={(event) => { const kind = event.target.value as ApprovalSelector["kind"]; patchSelector(stepIndex, selectorIndex, { kind, value: kind === "any_account" ? "*" : selector.value === "*" ? "" : selector.value }); }}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
|
||||||
|
<FormField label="Actor value"><input value={selector.value} disabled={disabled || selector.kind === "any_account"} onChange={(event) => patchSelector(stepIndex, selectorIndex, { value: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Display label"><input value={selector.label ?? ""} disabled={disabled} onChange={(event) => patchSelector(stepIndex, selectorIndex, { label: event.target.value || null })} /></FormField>
|
||||||
|
<IconButton label="Remove actor selector" icon={<Trash2 />} variant="danger" disabled={disabled || step.selectors.length === 1} onClick={() => patchStep(stepIndex, { selectors: step.selectors.filter((_, index) => index !== selectorIndex) })} />
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</section>)}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyApprovalStep(index: number): ApprovalStep {
|
||||||
|
return {
|
||||||
|
key: `step-${index}`,
|
||||||
|
label: "",
|
||||||
|
selectors: [emptySelector()],
|
||||||
|
required_approvals: 1,
|
||||||
|
rejection_policy: "fail_fast",
|
||||||
|
due_at: null,
|
||||||
|
signature_required: false,
|
||||||
|
forbidden_evidence_roles: [],
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySelector(): ApprovalSelector {
|
||||||
|
return { kind: "account", value: "", label: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commaList(value: string): string[] {
|
||||||
|
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminIconButton,
|
||||||
|
AdminPageLayout,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
ToggleSwitch,
|
||||||
|
adminErrorMessage,
|
||||||
|
formatAdminDateTime as formatDateTime,
|
||||||
|
type ApiSettings,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
approvalTemplateHistory,
|
||||||
|
compareApprovalTemplateRevisions,
|
||||||
|
createApprovalTemplate,
|
||||||
|
listApprovalTemplates,
|
||||||
|
publishApprovalTemplate,
|
||||||
|
reviseApprovalTemplate,
|
||||||
|
type ApprovalTemplate,
|
||||||
|
type ApprovalTemplateChange,
|
||||||
|
type ApprovalTemplateComparison,
|
||||||
|
type ApprovalTemplateDraft
|
||||||
|
} from "../../api/approvals";
|
||||||
|
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
|
||||||
|
import { APPROVALS_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
type EditorState = { mode: "create" | "edit"; current?: ApprovalTemplate };
|
||||||
|
|
||||||
|
export default function ApprovalTemplatesPanel({
|
||||||
|
settings,
|
||||||
|
canAdmin
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
canAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const [templates, setTemplates] = useState<ApprovalTemplate[]>([]);
|
||||||
|
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||||
|
const [draft, setDraft] = useState<ApprovalTemplateDraft>(emptyTemplate());
|
||||||
|
const [publishing, setPublishing] = useState<ApprovalTemplate | null>(null);
|
||||||
|
const [historyTemplate, setHistoryTemplate] = useState<ApprovalTemplate | null>(null);
|
||||||
|
const [history, setHistory] = useState<ApprovalTemplate[]>([]);
|
||||||
|
const [fromRevision, setFromRevision] = useState(1);
|
||||||
|
const [toRevision, setToRevision] = useState(1);
|
||||||
|
const [comparison, setComparison] = useState<ApprovalTemplateComparison | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setTemplates(await listApprovalTemplates(settings));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(adminErrorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
|
||||||
|
{ id: "title", header: "Template", width: "minmax(220px, 1fr)", minWidth: 190, fill: true, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => row.title, render: (row) => <div><strong>{row.title}</strong><div className="muted small-note">{row.key}</div></div> },
|
||||||
|
{ id: "state", header: "State", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
|
||||||
|
{ id: "revision", header: "Revision", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.revision },
|
||||||
|
{ id: "steps", header: "Steps", width: 90, resizable: false, sortable: true, value: (row) => row.steps.length },
|
||||||
|
{ id: "recorded", header: "Updated", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
|
||||||
|
{ id: "actions", header: "Actions", width: 132, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
|
{ id: "edit", label: `Revise ${row.title}`, icon: <Pencil />, disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => openEdit(row) },
|
||||||
|
{ id: "history", label: `History for ${row.title}`, icon: <History />, onClick: () => void openHistory(row) },
|
||||||
|
{ id: "publish", label: `Publish ${row.title}`, icon: <Send />, applicable: row.state === "draft", disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => setPublishing(row) }
|
||||||
|
]} /> }
|
||||||
|
], [canAdmin]);
|
||||||
|
|
||||||
|
const historyColumns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
|
||||||
|
{ id: "revision", header: "Revision", width: 90, resizable: false, value: (row) => row.revision },
|
||||||
|
{ id: "state", header: "State", width: 110, resizable: false, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
|
||||||
|
{ id: "recorded", header: "Recorded", width: 180, resizable: true, fill: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
|
||||||
|
{ id: "actor", header: "Actor", width: "minmax(160px, 1fr)", minWidth: 140, resizable: true, value: (row) => row.actor_id || "", render: (row) => row.actor_id || "System" },
|
||||||
|
{ id: "hash", header: "Content hash", width: 180, resizable: true, value: (row) => row.content_sha256, render: (row) => <code title={row.content_sha256}>{row.content_sha256.slice(0, 16)}...</code> }
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const changeColumns = useMemo<DataGridColumn<ApprovalTemplateChange>[]>(() => [
|
||||||
|
{ id: "path", header: "Path", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sticky: "start", value: (row) => row.path, render: (row) => <code>{row.path}</code> },
|
||||||
|
{ id: "change", header: "Change", width: 110, resizable: false, value: (row) => row.change, render: (row) => <StatusBadge status={row.change} /> },
|
||||||
|
{ id: "before", header: "Before", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.before), render: (row) => <code className="approval-diff-value">{printable(row.before)}</code> },
|
||||||
|
{ id: "after", header: "After", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.after), render: (row) => <code className="approval-diff-value">{printable(row.after)}</code> }
|
||||||
|
], []);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setDraft(emptyTemplate());
|
||||||
|
setEditor({ mode: "create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(current: ApprovalTemplate) {
|
||||||
|
setDraft({
|
||||||
|
key: current.key,
|
||||||
|
title: current.title,
|
||||||
|
description: current.description ?? "",
|
||||||
|
steps: structuredClone(current.steps),
|
||||||
|
separation_of_duties: current.separation_of_duties,
|
||||||
|
unique_actors_across_steps: current.unique_actors_across_steps,
|
||||||
|
metadata: { ...current.metadata }
|
||||||
|
});
|
||||||
|
setEditor({ mode: "edit", current });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editor) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = editor.mode === "create"
|
||||||
|
? await createApprovalTemplate(settings, draft)
|
||||||
|
: await reviseApprovalTemplate(settings, editor.current!, draft);
|
||||||
|
setEditor(null);
|
||||||
|
setSuccess(`${saved.title} saved as draft revision ${saved.revision}.`);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(adminErrorMessage(reason));
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
if (!publishing) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const published = await publishApprovalTemplate(settings, publishing);
|
||||||
|
setPublishing(null);
|
||||||
|
setSuccess(`${published.title} revision ${published.revision} published.`);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(adminErrorMessage(reason));
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHistory(template: ApprovalTemplate) {
|
||||||
|
setHistoryTemplate(template);
|
||||||
|
setComparison(null);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const revisions = await approvalTemplateHistory(settings, template.id);
|
||||||
|
setHistory(revisions);
|
||||||
|
const newest = revisions[0]?.revision ?? template.revision;
|
||||||
|
const oldest = revisions.at(-1)?.revision ?? newest;
|
||||||
|
setFromRevision(oldest);
|
||||||
|
setToRevision(newest);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(adminErrorMessage(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compare() {
|
||||||
|
if (!historyTemplate) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setComparison(await compareApprovalTemplateRevisions(settings, historyTemplate.id, fromRevision, toRevision));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(adminErrorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = Boolean(
|
||||||
|
draft.key.trim()
|
||||||
|
&& draft.title.trim()
|
||||||
|
&& draft.steps.length
|
||||||
|
&& draft.steps.every((step) => step.key.trim() && step.label.trim() && step.required_approvals > 0 && step.selectors.length && step.selectors.every((selector) => selector.value.trim()))
|
||||||
|
);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<AdminPageLayout title="Approval templates" description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Create approval template" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} /></>}>
|
||||||
|
<div className="admin-table-surface"><DataGrid id="approval-templates-v1" rows={templates} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No approval templates found." /></div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(editor)} title={editor?.mode === "create" ? "Create approval template" : "Revise approval template"} onClose={() => !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<><Button onClick={() => setEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !valid || !canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : !valid ? APPROVALS_I18N.incomplete : busy ? APPROVALS_I18N.busy : undefined}>{busy ? "Saving..." : "Save draft revision"}</Button></>}>
|
||||||
|
<div className="approval-editor">
|
||||||
|
<div className="approval-editor-grid">
|
||||||
|
<FormField label="Stable key"><input value={draft.key} disabled={busy || editor?.mode === "edit"} onChange={(event) => setDraft({ ...draft, key: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
|
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
|
||||||
|
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
|
||||||
|
</div>
|
||||||
|
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(historyTemplate)} title={`${historyTemplate?.title ?? "Template"} history`} onClose={() => !busy && setHistoryTemplate(null)} className="approval-template-history-dialog" footer={<Button onClick={() => setHistoryTemplate(null)} disabled={busy}>Close</Button>}>
|
||||||
|
<div className="approval-template-history-layout">
|
||||||
|
<div className="admin-table-surface"><DataGrid id="approval-template-history-v1" rows={history} columns={historyColumns} initialFit="container" getRowKey={(row) => `${row.id}:${row.revision}`} emptyText="No template revisions found." /></div>
|
||||||
|
<div className="approval-compare-toolbar">
|
||||||
|
<FormField label="From revision"><select value={fromRevision} onChange={(event) => setFromRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
|
||||||
|
<FormField label="To revision"><select value={toRevision} onChange={(event) => setToRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
|
||||||
|
<Button onClick={() => void compare()} disabled={busy || !history.length}><GitCompareArrows aria-hidden="true" />Compare</Button>
|
||||||
|
</div>
|
||||||
|
{comparison && <div className="admin-table-surface"><DataGrid id="approval-template-compare-v1" rows={comparison.changes} columns={changeColumns} initialFit="container" getRowKey={(row) => `${row.path}:${row.change}`} emptyText="These revisions have identical template content." /></div>}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={Boolean(publishing)} title="Publish approval template" message={`Publish ${publishing?.title ?? "this template"}? Requests can then bind permanently to the new immutable revision.`} confirmLabel="Publish revision" busy={busy} onCancel={() => setPublishing(null)} onConfirm={() => void publish()} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTemplate(): ApprovalTemplateDraft {
|
||||||
|
return {
|
||||||
|
key: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
steps: [emptyApprovalStep(1)],
|
||||||
|
separation_of_duties: true,
|
||||||
|
unique_actors_across_steps: false,
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printable(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
|
||||||
|
import ApprovalRequestDialog from "./ApprovalRequestDialog";
|
||||||
|
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const [items, setItems] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<ApprovalRequest | null>(null);
|
||||||
|
const [history, setHistory] = useState<ApprovalEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [decision, setDecision] = useState<"approved" | "rejected" | null>(null);
|
||||||
|
const [escalating, setEscalating] = useState(false);
|
||||||
|
const canWrite = hasScope(auth, "approvals:workspace:write");
|
||||||
|
const canDecide = hasScope(auth, "approvals:workspace:decide");
|
||||||
|
const canAdmin = hasScope(auth, "approvals:workspace:admin");
|
||||||
|
const currentStep = selected?.steps[selected.current_step_index];
|
||||||
|
const escalationDue = Boolean(currentStep?.due_at && new Date(currentStep.due_at).getTime() <= Date.now());
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal, preferred?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await listApprovals(settings, signal);
|
||||||
|
setItems(response.requests);
|
||||||
|
setSelectedId((current) => preferred ?? current ?? response.requests[0]?.id ?? null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval requests could not be loaded.")); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setSelected(null); setHistory([]); return; }
|
||||||
|
const controller = new AbortController();
|
||||||
|
Promise.all([getApproval(settings, selectedId, controller.signal), approvalHistory(settings, selectedId, controller.signal)]).then(([item, events]) => { setSelected(item); setHistory(events); }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval details could not be loaded.")); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [selectedId, settings]);
|
||||||
|
|
||||||
|
async function reload(id: string) {
|
||||||
|
const [item, events] = await Promise.all([getApproval(settings, id), approvalHistory(settings, id)]);
|
||||||
|
setSelected(item);
|
||||||
|
setHistory(events);
|
||||||
|
await load(undefined, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="approvals-page"><div className="approvals-shell">
|
||||||
|
<aside className="approvals-list-panel">
|
||||||
|
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div>
|
||||||
|
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport>
|
||||||
|
</aside>
|
||||||
|
<section className="approvals-workspace">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />}
|
||||||
|
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
|
||||||
|
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canAdmin && selected.state === "pending" && <Button disabled={busy || !escalationDue} disabledReason={busy ? APPROVALS_I18N.busy : !escalationDue ? "The current step is not due for escalation." : undefined} onClick={() => setEscalating(true)}><AlarmClock size={16} aria-hidden="true" />Escalate</Button>}<Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header>
|
||||||
|
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div>
|
||||||
|
{selected.description && <p>{selected.description}</p>}
|
||||||
|
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
|
||||||
|
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section>
|
||||||
|
</div></PageScrollViewport>}
|
||||||
|
{!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
|
||||||
|
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />}
|
||||||
|
{selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<boolean> }) {
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [signatureId, setSignatureId] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const dirty = Boolean(reason || signatureId);
|
||||||
|
const valid = Boolean(reason.trim() && (!signatureRequired || signatureId.trim()));
|
||||||
|
useUnsavedDraftGuard({ dirty, onSave: async () => valid && onConfirm(reason.trim(), signatureId.trim()), onDiscard: () => { setReason(""); setSignatureId(""); }, title: "i18n:govoplan-approvals.unsaved_title", message: "i18n:govoplan-approvals.unsaved_message" });
|
||||||
|
const requestClose = () => { if (busy) return; if (dirty) requestDiscard(onClose); else onClose(); };
|
||||||
|
return <Dialog open title={`${humanize(outcome)} request`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div><FormField label="Reason" documentation={APPROVALS_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
|
||||||
|
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
|
||||||
|
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
|
||||||
|
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const APPROVALS_DOCUMENTATION = {
|
||||||
|
topicId: "approvals.module-boundary",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const APPROVALS_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "approvals.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const APPROVALS_I18N = {
|
||||||
|
loading: "i18n:govoplan-approvals.loading_reason",
|
||||||
|
busy: "i18n:govoplan-approvals.busy_reason",
|
||||||
|
writeReason: "i18n:govoplan-approvals.write_permission_reason",
|
||||||
|
decideReason: "i18n:govoplan-approvals.decide_permission_reason",
|
||||||
|
lifecycleReason: "i18n:govoplan-approvals.lifecycle_reason",
|
||||||
|
incomplete: "i18n:govoplan-approvals.incomplete_reason",
|
||||||
|
oneStep: "i18n:govoplan-approvals.one_step_reason",
|
||||||
|
requiredAction: "i18n:govoplan-approvals.required_action",
|
||||||
|
actor: "i18n:govoplan-approvals.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-approvals.destination",
|
||||||
|
permissionAction: "i18n:govoplan-approvals.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-approvals.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-approvals.permission_destination"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-approvals.approvals": "Approvals",
|
||||||
|
"i18n:govoplan-approvals.loading_reason": "Approval data is still loading.",
|
||||||
|
"i18n:govoplan-approvals.busy_reason": "Another Approval action is still running.",
|
||||||
|
"i18n:govoplan-approvals.write_permission_reason": "Your account may not create Approval requests.",
|
||||||
|
"i18n:govoplan-approvals.decide_permission_reason": "Your account may not decide this Approval request.",
|
||||||
|
"i18n:govoplan-approvals.lifecycle_reason": "Only pending or escalated requests can be decided.",
|
||||||
|
"i18n:govoplan-approvals.incomplete_reason": "Complete the required subject, digest, steps, and selector fields first.",
|
||||||
|
"i18n:govoplan-approvals.one_step_reason": "An Approval chain must retain at least one step.",
|
||||||
|
"i18n:govoplan-approvals.required_action": "Required action",
|
||||||
|
"i18n:govoplan-approvals.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-approvals.destination": "Destination",
|
||||||
|
"i18n:govoplan-approvals.permission_action": "Ask for the corresponding Approval permission.",
|
||||||
|
"i18n:govoplan-approvals.permission_actor": "An Access or tenant administrator",
|
||||||
|
"i18n:govoplan-approvals.permission_destination": "Access role assignments",
|
||||||
|
"i18n:govoplan-approvals.unsaved_title": "Unsaved Approval request",
|
||||||
|
"i18n:govoplan-approvals.unsaved_message": "Save or discard the Approval draft before leaving this surface.",
|
||||||
|
"Approvals": "Approvals",
|
||||||
|
"Refresh approvals": "Refresh approvals",
|
||||||
|
"New request": "New request",
|
||||||
|
"Loading approvals": "Loading approvals",
|
||||||
|
"No approval requests": "No approval requests",
|
||||||
|
"Select or create an approval request.": "Select or create an approval request.",
|
||||||
|
"No Approval creation permission": "No Approval creation permission",
|
||||||
|
"Approve": "Approve",
|
||||||
|
"Reject": "Reject",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Current step": "Current step",
|
||||||
|
"Requested by": "Requested by",
|
||||||
|
"Steps": "Steps",
|
||||||
|
"Complete": "Complete",
|
||||||
|
"Approval chain": "Approval chain",
|
||||||
|
"History": "History",
|
||||||
|
"Signature": "Signature",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Confirm": "Confirm",
|
||||||
|
"Reason": "Reason",
|
||||||
|
"Signature reference": "Signature reference",
|
||||||
|
"New Approval request": "New Approval request",
|
||||||
|
"Creating": "Creating",
|
||||||
|
"Create request": "Create request",
|
||||||
|
"Title": "Title",
|
||||||
|
"Subject module": "Subject module",
|
||||||
|
"Subject type": "Subject type",
|
||||||
|
"Subject ID": "Subject ID",
|
||||||
|
"Subject version": "Subject version",
|
||||||
|
"Subject SHA-256": "Subject SHA-256",
|
||||||
|
"Description": "Description",
|
||||||
|
"Separate requester and approver": "Separate requester and approver",
|
||||||
|
"Different actor for every step": "Different actor for every step",
|
||||||
|
"Add step": "Add step",
|
||||||
|
"Key": "Key",
|
||||||
|
"Label": "Label",
|
||||||
|
"Actor type": "Actor type",
|
||||||
|
"Actor value": "Actor value",
|
||||||
|
"Required": "Required",
|
||||||
|
"Account": "Account",
|
||||||
|
"Group": "Group",
|
||||||
|
"Role": "Role",
|
||||||
|
"Function assignment": "Function assignment",
|
||||||
|
"Any account": "Any account"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-approvals.approvals": "Genehmigungen",
|
||||||
|
"i18n:govoplan-approvals.loading_reason": "Genehmigungsdaten werden noch geladen.",
|
||||||
|
"i18n:govoplan-approvals.busy_reason": "Eine andere Genehmigungsaktion läuft noch.",
|
||||||
|
"i18n:govoplan-approvals.write_permission_reason": "Ihr Konto darf keine Genehmigungsanträge erstellen.",
|
||||||
|
"i18n:govoplan-approvals.decide_permission_reason": "Ihr Konto darf diesen Genehmigungsantrag nicht entscheiden.",
|
||||||
|
"i18n:govoplan-approvals.lifecycle_reason": "Nur ausstehende oder eskalierte Anträge können entschieden werden.",
|
||||||
|
"i18n:govoplan-approvals.incomplete_reason": "Füllen Sie zuerst Gegenstand, Prüfsumme, Schritte und Auswahlfelder aus.",
|
||||||
|
"i18n:govoplan-approvals.one_step_reason": "Eine Genehmigungskette muss mindestens einen Schritt behalten.",
|
||||||
|
"i18n:govoplan-approvals.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-approvals.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-approvals.destination": "Ziel",
|
||||||
|
"i18n:govoplan-approvals.permission_action": "Fordern Sie die entsprechende Genehmigungsberechtigung an.",
|
||||||
|
"i18n:govoplan-approvals.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
|
||||||
|
"i18n:govoplan-approvals.permission_destination": "Zugriff und Rollenzuweisungen",
|
||||||
|
"i18n:govoplan-approvals.unsaved_title": "Ungespeicherter Genehmigungsantrag",
|
||||||
|
"i18n:govoplan-approvals.unsaved_message": "Speichern oder verwerfen Sie den Genehmigungsentwurf, bevor Sie diese Oberfläche verlassen.",
|
||||||
|
"Approvals": "Genehmigungen",
|
||||||
|
"Refresh approvals": "Genehmigungen aktualisieren",
|
||||||
|
"New request": "Neuer Antrag",
|
||||||
|
"Loading approvals": "Genehmigungen werden geladen",
|
||||||
|
"No approval requests": "Keine Genehmigungsanträge",
|
||||||
|
"Select or create an approval request.": "Wählen oder erstellen Sie einen Genehmigungsantrag.",
|
||||||
|
"No Approval creation permission": "Keine Berechtigung zum Erstellen von Genehmigungen",
|
||||||
|
"Approve": "Genehmigen",
|
||||||
|
"Reject": "Ablehnen",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Current step": "Aktueller Schritt",
|
||||||
|
"Requested by": "Beantragt von",
|
||||||
|
"Steps": "Schritte",
|
||||||
|
"Complete": "Abgeschlossen",
|
||||||
|
"Approval chain": "Genehmigungskette",
|
||||||
|
"History": "Verlauf",
|
||||||
|
"Signature": "Signatur",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Confirm": "Bestätigen",
|
||||||
|
"Reason": "Grund",
|
||||||
|
"Signature reference": "Signaturreferenz",
|
||||||
|
"New Approval request": "Neuer Genehmigungsantrag",
|
||||||
|
"Creating": "Erstellen",
|
||||||
|
"Create request": "Antrag erstellen",
|
||||||
|
"Title": "Titel",
|
||||||
|
"Subject module": "Gegenstandsmodul",
|
||||||
|
"Subject type": "Gegenstandsart",
|
||||||
|
"Subject ID": "Gegenstands-ID",
|
||||||
|
"Subject version": "Gegenstandsversion",
|
||||||
|
"Subject SHA-256": "SHA-256 des Gegenstands",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Separate requester and approver": "Antragstellende und Genehmigende trennen",
|
||||||
|
"Different actor for every step": "Unterschiedliche Stelle für jeden Schritt",
|
||||||
|
"Add step": "Schritt hinzufügen",
|
||||||
|
"Key": "Schlüssel",
|
||||||
|
"Label": "Bezeichnung",
|
||||||
|
"Actor type": "Akteursart",
|
||||||
|
"Actor value": "Akteurswert",
|
||||||
|
"Required": "Erforderlich",
|
||||||
|
"Account": "Konto",
|
||||||
|
"Group": "Gruppe",
|
||||||
|
"Role": "Rolle",
|
||||||
|
"Function assignment": "Funktionszuordnung",
|
||||||
|
"Any account": "Beliebiges Konto"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, approvalsModule } from "./module";
|
||||||
|
export * from "./api/approvals";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/approvals.css";
|
||||||
|
|
||||||
|
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
|
||||||
|
const ApprovalTemplatesPanel = lazy(() => import("./features/approvals/ApprovalTemplatesPanel"));
|
||||||
|
|
||||||
|
const approvalsAdminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "tenant-approval-templates",
|
||||||
|
moduleId: "approvals",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "approvals.admin.templates",
|
||||||
|
label: "Approval templates",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 55,
|
||||||
|
anyOf: ["approvals:workspace:read", "approvals:workspace:admin"],
|
||||||
|
render: ({ settings, auth }) => createElement(ApprovalTemplatesPanel, {
|
||||||
|
settings,
|
||||||
|
canAdmin: hasScope(auth, "approvals:workspace:admin")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approvalsModule: PlatformWebModule = {
|
||||||
|
id: "approvals",
|
||||||
|
label: "i18n:govoplan-approvals.approvals",
|
||||||
|
version: "0.1.14",
|
||||||
|
dependencies: ["access"],
|
||||||
|
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
|
||||||
|
translations: generatedTranslations,
|
||||||
|
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
|
||||||
|
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "approvals.navigation", moduleId: "approvals", kind: "navigation", label: "Approvals navigation", order: 10 },
|
||||||
|
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 },
|
||||||
|
{ id: "approvals.admin.templates", moduleId: "approvals", kind: "section", label: "Approval templates", order: 30 }
|
||||||
|
],
|
||||||
|
uiCapabilities: {
|
||||||
|
"admin.sections": approvalsAdminSections
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default approvalsModule;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.approvals-page { height: 100%; min-height: 0; overflow: hidden; }
|
||||||
|
.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; }
|
||||||
|
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); }
|
||||||
|
.approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); }
|
||||||
|
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
|
||||||
|
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
|
||||||
|
.approvals-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; padding: 7px 8px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.approvals-list button:hover, .approvals-list button.is-selected { background: var(--hover-bg, rgba(54, 99, 135, 0.1)); }
|
||||||
|
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
|
||||||
|
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
|
||||||
|
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||||
|
.approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; }
|
||||||
|
.approvals-empty { display: grid; min-height: 120px; place-items: center; padding: 16px; color: var(--text-muted, #65717e); text-align: center; }
|
||||||
|
.approvals-detail { padding: 16px 20px 28px; }
|
||||||
|
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); }
|
||||||
|
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; }
|
||||||
|
.approval-metrics { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||||
|
.approval-metrics > div { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
|
||||||
|
.approval-metrics span { color: var(--text-muted, #65717e); font-size: .75rem; text-transform: uppercase; }
|
||||||
|
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-color, #d8dde3); }
|
||||||
|
.approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||||
|
.approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted, rgba(127,137,147,.08)); }
|
||||||
|
.approval-chain > div.is-current { border-left: 3px solid var(--accent-color, #36709a); }
|
||||||
|
.approval-chain em { font-size: .8rem; font-style: normal; }
|
||||||
|
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
|
||||||
|
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; }
|
||||||
|
.approval-request-dialog, .approval-template-dialog { width: min(1160px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
|
||||||
|
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
|
||||||
|
.approval-editor-help { display: flex; justify-content: flex-end; }
|
||||||
|
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.approval-editor-wide { grid-column: 1 / -1; }
|
||||||
|
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.approval-step-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.approval-step-editor { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
|
||||||
|
.approval-step-heading, .approval-selector-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.approval-step-heading > div { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.approval-step-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 8px; }
|
||||||
|
.approval-selector-heading { padding-top: 8px; border-top: 1px solid var(--border-color, #d8dde3); }
|
||||||
|
.approval-selector-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.approval-selector-list > div { display: grid; grid-template-columns: 180px minmax(160px, 1fr) minmax(160px, 1fr) 34px; align-items: end; gap: 8px; }
|
||||||
|
.approval-template-history-dialog { width: min(1180px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
|
||||||
|
.approval-template-history-layout { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 12px; overflow: hidden; }
|
||||||
|
.approval-template-history-layout > .admin-table-surface { min-height: 180px; flex: 1; overflow: auto; }
|
||||||
|
.approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; }
|
||||||
|
.approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
|
||||||
|
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const panel = readFileSync("src/features/approvals/ApprovalTemplatesPanel.tsx", "utf8");
|
||||||
|
const approvalsPage = readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8");
|
||||||
|
const api = readFileSync("src/api/approvals.ts", "utf8");
|
||||||
|
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||||
|
|
||||||
|
assert.match(panel, /Approval templates/);
|
||||||
|
assert.match(panel, /approvalTemplateHistory/);
|
||||||
|
assert.match(panel, /compareApprovalTemplateRevisions/);
|
||||||
|
assert.match(panel, /publishApprovalTemplate/);
|
||||||
|
assert.match(panel, /<ConfirmDialog[\s\S]*Publish approval template/);
|
||||||
|
assert.match(approvalsPage, /escalateApproval/);
|
||||||
|
assert.match(approvalsPage, /!escalationDue/);
|
||||||
|
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/history/);
|
||||||
|
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/compare/);
|
||||||
|
assert.match(moduleSource, /approvals\.admin\.templates/);
|
||||||
|
assert.doesNotMatch(`${panel}\n${approvalsPage}`, /window\.(?:alert|confirm|prompt)\(/);
|
||||||
|
|
||||||
|
console.log("Approval template administration UI structural contract passed.");
|
||||||
Reference in New Issue
Block a user