23 Commits
Author SHA1 Message Date
zemion 36781c86f7 fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 02:06:08 +02:00
zemion 7c9ae1f116 fix(webui): bind approval decisions to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:33 +02:00
zemion e864f840e3 docs(approvals): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 19:31:21 +02:00
zemion 5d3eeb0060 feat(approvals): add governed DSAR coverage 2026-08-21 12:06:09 +02:00
zemion 327b35888d refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion eded5dbaf1 feat: align approvals with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 183a091539 Adopt shared WebUI structural primitives 2026-08-18 13:17:28 +02:00
zemion 9731daae4a Adopt shared WebUI layout primitives 2026-08-18 10:42:50 +02:00
zemion 4cf870f322 refactor: adopt shared split workspace 2026-08-18 02:17:21 +02:00
zemion b691a0c2d5 Project pending approvals as authorized work 2026-08-06 16:06:17 +02:00
zemion 956624be0e Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion a3232f6e12 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:57 +02:00
zemion ad9a580ad3 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:51:58 +02:00
zemion 1ac44af8d0 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:12 +02:00
zemion 9bea71d87e Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 532e614f7b Harden module package publication 2026-08-04 14:02:39 +02:00
zemion b7c2411819 Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 7f64247625 Add approval template revision administration 2026-08-04 01:04:39 +02:00
zemion 3abbe117e5 Assign Approvals a unique migration revision 2026-08-03 15:04:39 +02:00
zemion 24e95591e0 Migrate Approvals interface patterns 2026-08-03 12:55:27 +02:00
zemion 62d0f300cb Implement generic approval runtime 2026-08-01 20:57:26 +02:00
zemion 2b081cb931 docs: declare institutional architecture boundary 2026-08-01 17:48:22 +02:00
zemion 805ac1c8de refactor: target workflow engine runtime 2026-07-31 16:59:21 +02:00
36 changed files with 5904 additions and 111 deletions
+270
View File
@@ -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
+6
View File
@@ -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.
+35 -6
View File
@@ -4,9 +4,7 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`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-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.
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.
## Initial Ownership ## Initial Ownership
@@ -27,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
@@ -45,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
@@ -62,3 +73,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
/mnt/DATA/git/govoplan/tools/gitea/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
``` ```
## Git-source WebUI package
The repository root exposes `@govoplan/approvals-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/approvals-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+45 -32
View File
@@ -1,44 +1,57 @@
# 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 When Tasks is enabled, Approvals projects only currently actionable steps into
- workflow orchestration engine the common work inbox. The projection applies the same selector, expiration,
- identity and RBAC primitives prior-decision, unique-actor, evidence-role, and requester-separation checks as
the decision command. Approvals remains the owner of decision and completion
state; Tasks receives no copied approval record.
## Integration Candidates ## Recovery and scale-out
- workflow All API and worker nodes use the logically shared database. Back up and restore
- audit these tables as one consistency unit:
- files
- notifications
## Seed State - `approval_request_revisions`
- `approval_decision_records`
- `approval_lifecycle_events`
- `approval_replays`
The current repository state is intentionally small: 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.
- module manifest and entry point ## Optional integrations
- tenant-level permission definitions
- manager and viewer role templates
- documentation topic describing the module boundary
- Gitea issue workflow templates
- manifest contract test
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. Workflow Engine may wait for completion, Tasks may aggregate actionable work,
and Notifications may announce an assignment, due date, escalation, or
## First Implementation Slice outcome. Audit may retain additional cross-domain evidence. Policy may provide
chain templates. These integrations use capabilities and events; none reads
Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules. Approval tables directly.
+32
View File
@@ -0,0 +1,32 @@
# 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, draft-guard, and `WorkspaceLayout` contracts. The shared split-pane
shell keeps the request collection and selected evidence in independently
scrollable panes, then stacks them at the platform narrow-layout breakpoint.
Native selection buttons preserve keyboard order. English and German catalogues
cover module-owned copy and dates follow the active platform locale.
+29 -4
View File
@@ -1,8 +1,33 @@
{ {
"name": "@govoplan/approvals", "name": "@govoplan/approvals-webui",
"version": "0.1.8", "version": "0.1.21",
"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": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/approvals.css": "./webui/src/styles/approvals.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
} }
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-approvals" name = "govoplan-approvals"
version = "0.1.8" version = "0.1.21"
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.8", "govoplan-core>=0.1.18",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,13 @@
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalReplay,
)
__all__ = [
"ApprovalDecisionRecord",
"ApprovalLifecycleEvent",
"ApprovalReplay",
"ApprovalRequestRevision",
]
+194
View File
@@ -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",
]
@@ -0,0 +1,427 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalTemplateRevision,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
APPROVALS_DSAR_CAPABILITY = dsar_capability_name("approvals")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
request_id: str | None
class ApprovalsDsarProvider:
provider_id = "approvals"
module_id = "approvals"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
decisions = db.query(ApprovalDecisionRecord).filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
or_(
ApprovalDecisionRecord.actor_id.in_(selectors.actor_ids),
ApprovalDecisionRecord.effective_actor_id.in_(selectors.actor_ids),
),
)
requests = db.query(ApprovalRequestRevision).filter(
ApprovalRequestRevision.tenant_id == tenant_id,
ApprovalRequestRevision.actor_id.in_(selectors.actor_ids),
)
events = db.query(ApprovalLifecycleEvent).filter(
ApprovalLifecycleEvent.tenant_id == tenant_id,
ApprovalLifecycleEvent.actor_id.in_(selectors.actor_ids),
)
templates = db.query(ApprovalTemplateRevision).filter(
ApprovalTemplateRevision.tenant_id == tenant_id,
ApprovalTemplateRevision.actor_id.in_(selectors.actor_ids),
)
if selectors.request_id:
decisions = decisions.filter(
ApprovalDecisionRecord.request_id == selectors.request_id
)
requests = requests.filter(
ApprovalRequestRevision.request_id == selectors.request_id
)
events = events.filter(
ApprovalLifecycleEvent.request_id == selectors.request_id
)
templates = templates.filter(False)
records.extend(
_decision_record(row, selectors.actor_ids)
for row in _limited(
decisions,
ApprovalDecisionRecord.recorded_at,
ApprovalDecisionRecord.id,
label="decision",
)
)
records.extend(
_request_attribution(row)
for row in _limited(
requests,
ApprovalRequestRevision.recorded_at,
ApprovalRequestRevision.id,
label="request attribution",
)
)
records.extend(
_event_attribution(row)
for row in _limited(
events,
ApprovalLifecycleEvent.recorded_at,
ApprovalLifecycleEvent.id,
label="lifecycle attribution",
)
)
records.extend(
_template_attribution(row)
for row in _limited(
templates,
ApprovalTemplateRevision.recorded_at,
ApprovalTemplateRevision.id,
label="template attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Approvals DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Approvals DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
authored_reason = record.resource_type == "approval_decision_participation"
actions.append(
DsarErasureActionRef(
action_id=(
f"approvals:{'manual_review' if authored_reason else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if authored_reason else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if authored_reason else "Retain ") + record.title,
rationale=(
"The authored decision reason may contain personal data, but "
"any minimization must preserve the immutable approval chain, "
"signature evidence, and the consuming subject's legal state."
if authored_reason
else record.retention_reason
or "Approval attribution remains immutable evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Approvals DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Approvals DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The decision remains unchanged pending legal, signature, and "
"approval-chain review."
if action.kind == "manual_review"
else "Approval lifecycle attribution remains immutable evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("approvals.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("approvals.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("approvals.identity"),
references.get("identity.id"),
),
"actor_id": _coalesce(
references.get("approvals.actor"),
references.get("approvals.effective_actor"),
),
"request_id": _coalesce(
references.get("approvals.request"),
references.get("approvals.request_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_actor = _optional_string(values["actor_id"])
if direct_actor:
if actor_ids and direct_actor not in actor_ids:
return None
if not actor_ids:
actor_ids = (direct_actor,)
if not actor_ids:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
request_id=_optional_string(values["request_id"]),
)
def _decision_record(
row: ApprovalDecisionRecord,
actor_ids: Sequence[str],
) -> DsarRecordRef:
actor_set = set(actor_ids)
activities = []
if row.actor_id in actor_set:
activities.append("recorded_decision")
if row.effective_actor_id in actor_set:
activities.append("effective_decision_actor")
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_decision_participation",
resource_id=row.id,
category="institutional_approval_participation",
title="Approval decision participation",
data={
"request_id": row.request_id,
"request_revision": row.request_revision,
"step_key": row.step_key,
"outcome": row.outcome,
"reason": row.reason[:4_000],
"activities": activities,
"delegation_id": row.delegation_id,
"recorded_at": _iso(row.recorded_at),
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason=(
"Approval decisions and their reasons are immutable institutional evidence."
),
)
def _request_attribution(row: ApprovalRequestRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_request_actor_attribution",
resource_id=row.id,
category="approval_lifecycle_attribution",
title="Approval request actor attribution",
data={
"request_id": row.request_id,
"revision": row.revision,
"state": row.state,
"current_step_key": row.current_step_key,
"subject_module": row.subject_module,
"subject_type": row.subject_type,
"subject_id": row.subject_id,
"subject_version": row.subject_version,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_request_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval request attribution is immutable lifecycle evidence.",
)
def _event_attribution(row: ApprovalLifecycleEvent) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_lifecycle_actor_attribution",
resource_id=row.id,
category="approval_lifecycle_attribution",
title="Approval lifecycle actor attribution",
data={
"request_id": row.request_id,
"sequence": row.sequence,
"event_type": row.event_type,
"recorded_at": _iso(row.recorded_at),
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval lifecycle attribution is immutable evidence.",
)
def _template_attribution(row: ApprovalTemplateRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_template_actor_attribution",
resource_id=row.id,
category="approval_configuration_attribution",
title="Approval template actor attribution",
data={
"template_id": row.template_id,
"key": row.key,
"revision": row.revision,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_template_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval template attribution is governance evidence.",
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Approvals DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Approvals DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"approval_decision_participation",
"approval_request_actor_attribution",
"approval_lifecycle_actor_attribution",
"approval_template_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "approvals" or record.module_id != "approvals":
raise ValueError("Approvals DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Approvals DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "approvals" or action.module_id != "approvals":
raise ValueError("Approvals DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("approvals:"):
raise ValueError("Approvals DSAR action identity is invalid.")
__all__ = ["APPROVALS_DSAR_CAPABILITY", "ApprovalsDsarProvider"]
+554 -57
View File
@@ -1,19 +1,58 @@
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,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tasks import WorkItemProviderRegistration
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.dsar_provider import (
APPROVALS_DSAR_CAPABILITY,
ApprovalsDsarProvider,
)
from govoplan_approvals.backend.service import SqlApprovalRequests
MODULE_ID = "approvals" MODULE_ID = "approvals"
MODULE_NAME = "Approvals" MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.8" MODULE_VERSION = "0.1.21"
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", "workflow_engine",
"audit", "audit",
"files", "files",
"notifications", "notifications",
"policy",
"tasks",
) )
@@ -23,7 +62,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 +70,38 @@ 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", def _requests(_context: ModuleContext) -> SqlApprovalRequests:
description="Manage approvals records and workflow state.", return SqlApprovalRequests()
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate( def _dsar_provider(_context: ModuleContext) -> ApprovalsDsarProvider:
slug="approvals_viewer", return ApprovalsDsarProvider()
name="Approvals viewer",
description="Read approvals records and workflow context.",
permissions=(READ_SCOPE,), def _work_items(_context: ModuleContext):
), from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
)
return ApprovalWorkItemProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
approval_models.ApprovalRequestRevision.superseded_at.is_(None),
)
return {
"approval_requests": current.count(),
"approval_pending": current.filter(
approval_models.ApprovalRequestRevision.state.in_(("pending", "escalated"))
).count(),
}
DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
),
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.",
},
),
)
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
@@ -88,10 +109,486 @@ 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"),
ModuleInterfaceProvider(name=APPROVALS_DSAR_CAPABILITY, 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,
),
),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("approvals.nav.approvals", "approvals.route.approvals"),
order=10,
),
),
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,
APPROVALS_DSAR_CAPABILITY: _dsar_provider,
},
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",
),
APPROVALS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Approvals data-subject request provider",
summary=(
"Exports personal decision participation and minimized actor "
"attribution without exposing immutable approval internals."
),
contract_version="0.1.0",
),
},
work_item_providers=(
WorkItemProviderRegistration(
id="approvals.pending",
factory=_work_items,
order=30,
),
),
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.data-subject-requests",
title="Approval data-subject requests",
summary=(
"Export a subject's approval decisions and minimized lifecycle "
"attribution without disclosing unrelated chain content."
),
body=(
"Approvals correlates exact account, membership, identity, or explicit "
"actor identifiers inside the active tenant. An optional request "
"identifier only narrows an already verified actor search and never "
"discloses a request by itself. Authored decisions include their bounded "
"reason, step, outcome, delegation reference, and actor activities. "
"Request, lifecycle, and template activity is minimized to attribution "
"and stable context. Approval payloads, authority provenance, signature "
"objects, hashes, idempotency keys, and replay state are excluded. "
"Decision-reason erasure requires manual legal and chain-integrity "
"review; all other attribution remains immutable evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "access", "workflow_engine", "audit"),
metadata={
"kind": "reference",
"help_contexts": [
"approvals.workspace",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_decision_participation": (
"Returns bounded subject-authored decision evidence."
),
"review_reason_erasure": (
"Requires legal and approval-chain integrity review."
),
"retain_attribution": (
"Preserves minimized immutable lifecycle evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Genehmigungen",
"summary": (
"Genehmigungsentscheidungen einer betroffenen Person und minimierte Lebenszykluszuordnungen ausgeben, "
"ohne Inhalte fremder Genehmigungsketten offenzulegen."
),
"body": (
"Approvals gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
"Akteurskennungen ab. Eine optionale Antragskennung schränkt nur eine bereits verifizierte "
"Akteurssuche ein und legt für sich allein keinen Antrag offen. Von der Person verfasste Entscheidungen "
"enthalten den begrenzten Grund, Schritt, Ausgang, Delegationsverweis und Akteursaktivitäten. Antrags-, "
"Lebenszyklus- und Vorlagenaktivitäten werden auf Zuordnung und stabilen Kontext minimiert. "
"Genehmigungsinhalte, Herkunft der Befugnis, Signaturobjekte, Prüfsummen, Idempotenzschlüssel und "
"Wiederholungszustand bleiben ausgeschlossen. Das Löschen eines Entscheidungsgrunds erfordert eine "
"manuelle rechtliche Prüfung und Integritätsprüfung der Genehmigungskette; alle übrigen Zuordnungen "
"bleiben unveränderliche Nachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_decision_participation": "Gibt begrenzte, von der betroffenen Person verfasste Entscheidungsnachweise zurück.",
"review_reason_erasure": "Erfordert eine rechtliche Prüfung und eine Integritätsprüfung der Genehmigungskette.",
"retain_attribution": "Bewahrt minimierte, unveränderliche Lebenszyklusnachweise.",
}
}
},
),
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. "
"When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks. "
"The workspace keeps the permission-filtered request collection and selected evidence in separately scrollable panes and stacks them at narrow widths without losing selection."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Approvals boundary and recovery",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"kind": "workflow",
"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.",
],
},
translations={
"de": {
"title": "Gesteuerte Genehmigungsketten",
"summary": (
"Genehmigungsketten für exakt bezeichnete Gegenstände mit Delegation, Funktionstrennung, Eskalation und Signaturnachweisen erstellen."
),
"body": (
"Ein Genehmigungsantrag fixiert Gegenstandsrevision, geordnete Schritte, zulässige Selektoren, Quorum, "
"Ablehnungsregel, Signaturanforderung und Governance-Verweise. Entscheidungen werden nur angefügt, sind "
"mandantengebunden, durch optimistische Nebenläufigkeit geschützt und wiederholungssicher. Verbrauchende "
"Module prüfen den exakten Gegenstand über die Fähigkeit, statt Approval-Tabellen zu lesen. Wenn Tasks "
"aktiv ist, erscheint ein offener Schritt nur für Personen im gemeinsamen Arbeitseingang, die die exakte "
"Entscheidungsberechtigung aktuell erfüllen. Der Arbeitsbereich hält die berechtigungsgefilterte Sammlung "
"und den ausgewählten Nachweis in getrennt scrollbaren Bereichen und stapelt sie bei schmaler Darstellung, "
"ohne die Auswahl zu verlieren."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"privacy_notes": [
"Genehmigungslisten und Verläufe bleiben mandantengebunden und berechtigungsgefiltert.",
"Signaturverweise bezeichnen Nachweise, legen aber kein privates Schlüsselmaterial offen.",
"Der Entscheidungsverlauf bewahrt Akteur und Grund als gesteuerten Nachweis.",
]
}
},
),
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={
"kind": "reference",
"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.",
},
},
translations={
"de": {
"title": "Genehmigungsfelder und Folgen",
"summary": (
"Folgen exakter Gegenstandsidentität, Selektoren, Funktionstrennung, Signatur und Entscheidung."
),
"body": (
"Gegenstandsmodul, Typ, Kennung, Version und SHA-256-Prüfsumme fixieren die exakte zu genehmigende "
"Objektrevision. Geordnete Schritte, Akteursselektoren, erforderliche Anzahlen, Trennung vom Antragsteller, "
"eindeutige Akteure und Signaturanforderungen werden in den unveränderlichen Antrag kopiert und folgen "
"späteren Vorlagenänderungen nicht. Akteurswerte sind anbieterneutrale Kennungen, die über Access- und "
"IDM-Verträge interpretiert werden. Genehmigung oder Ablehnung fügt eine Entscheidung mit Akteur, Grund, "
"optionalem Signaturverweis und Nebenläufigkeitsrevision an. Abgeschlossene, abgelehnte, abgebrochene und "
"abgelaufene Anträge bleiben Nachweise und können nicht erneut entschieden werden."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"create_request": "Fixiert einen exakten Gegenstand und eine unveränderliche Genehmigungskette.",
"approve_step": "Fügt eine zuordenbare Entscheidung an und kann die Kette fortsetzen oder abschließen.",
"reject_request": "Fügt eine Ablehnung an und beendet den Antrag gemäß seiner fixierten Regel.",
"retain_evidence": "Bewahrt Antragsrevisionen, Entscheidungen, Gründe und Signaturverweise für die Rekonstruktion.",
}
}
},
),
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"),
conditions=(DocumentationCondition(required_scopes=(ADMIN_SCOPE,)),),
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={
"kind": "workflow",
"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.",
},
},
translations={
"de": {
"title": "Genehmigungsvorlagen verwalten",
"summary": (
"Wiederverwendbare Genehmigungsketten erstellen, unveränderliche Revisionen veröffentlichen, Verläufe vergleichen und Schritte erst nach ihrer Fälligkeit eskalieren."
),
"body": (
"Genehmigungsadministratoren verwalten Vorlagen unter Administration > Mandant > Genehmigungsvorlagen. "
"Ein stabiler Schlüssel bezeichnet die Vorlage; jede Bearbeitung erzeugt eine neue Entwurfsrevision mit "
"eigener Inhaltsprüfsumme, Akteur, Vorgänger und Zeitangabe. Die Veröffentlichung erzeugt eine weitere "
"unveränderliche Revision, an die neue Anträge exakt gebunden werden können; bestehende Anträge folgen "
"späteren Änderungen nie. Der Verlaufsdialog vergleicht zwei mandantensichtbare Revisionen als "
"deterministische JSON-Pointer-Änderungen, ohne unveränderte Nachweise auszublenden. Antragsbetreiber mit "
"Genehmigungsadministrationsrecht sehen Eskalieren nur bei offenen Anträgen und erst nach Fälligkeit des "
"aktuellen Schritts. Das Backend prüft Fälligkeit und Nebenläufigkeitsrevision erneut, bevor es den "
"Lebenszyklusübergang festhält."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"revise_template": "Ersetzt die aktuelle Vorlage und erzeugt eine neue Entwurfsrevision.",
"publish_template": "Erzeugt eine unveränderliche veröffentlichte Revision für neue Anträge.",
"escalate_request": "Hält fest, dass der aktuell fällige Schritt eskaliert wurde, ohne ihn zu entscheiden.",
}
}
},
),
),
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."""
@@ -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")
+379
View File
@@ -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"]
+178
View File
@@ -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,232 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from urllib.parse import quote
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalRequestRevision,
)
from govoplan_approvals.backend.service import (
ApprovalStoreError,
approval_decision_context,
)
PROVIDER_ID = "approvals.pending"
READ_SCOPE = "approvals:workspace:read"
DECIDE_SCOPE = "approvals:workspace:decide"
class ApprovalWorkItemProvider:
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Approval work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id:
return WorkItemPage(items=(), total=0)
if not has_scope(principal, READ_SCOPE) or not has_scope(
principal, DECIDE_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
rows = list(
session.scalars(
select(ApprovalRequestRevision)
.where(
ApprovalRequestRevision.tenant_id == query.tenant_id,
ApprovalRequestRevision.superseded_at.is_(None),
ApprovalRequestRevision.state.in_(("pending", "escalated")),
)
.order_by(
ApprovalRequestRevision.recorded_at.asc(),
ApprovalRequestRevision.request_id.asc(),
)
)
)
prior_by_request: dict[str, list[ApprovalDecisionRecord]] = defaultdict(list)
if rows and principal.account_id:
decisions = session.scalars(
select(ApprovalDecisionRecord).where(
ApprovalDecisionRecord.tenant_id == query.tenant_id,
ApprovalDecisionRecord.effective_actor_id == principal.account_id,
ApprovalDecisionRecord.request_id.in_(
tuple(row.request_id for row in rows)
),
)
)
for decision in decisions:
prior_by_request[decision.request_id].append(decision)
items: list[WorkItem] = []
total = 0
now = datetime.now(UTC)
for row in rows:
try:
context = approval_decision_context(
session,
principal,
row,
prior_decisions=prior_by_request.get(row.request_id, ()),
)
except ApprovalStoreError:
continue
item = _work_item(row, context.step, now=now)
if query.priorities and item.priority not in query.priorities:
continue
if query.due_before is not None and (
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
):
continue
if query.text and query.text.casefold() not in _search_text(item):
continue
total += 1
if len(items) < query.limit:
items.append(item)
items.sort(key=_sort_key)
return WorkItemPage(
items=tuple(items),
total=total,
truncated=total > len(items),
)
def _work_item(
row: ApprovalRequestRevision,
step: Mapping[str, object],
*,
now: datetime,
) -> WorkItem:
payload = dict(row.payload or {})
due_at = _date(step.get("due_at")) or _date(payload.get("expires_at"))
priority = "high" if row.state == "escalated" else "normal"
if due_at is not None and _aware(due_at) < now:
priority = "urgent"
title = str(payload.get("title") or "Approval required").strip()
step_label = str(step.get("label") or row.current_step_key or "Decide").strip()
action_url = f"/approvals?request={quote(row.request_id, safe='')}"
return WorkItem(
id=row.request_id,
provider_id=PROVIDER_ID,
owner_module="approvals",
tenant_id=row.tenant_id,
title=title,
summary=(
str(payload.get("description") or "").strip()
or f"{row.subject_module}: {row.subject_type}"
),
status="open",
priority=priority, # type: ignore[arg-type]
required_action=step_label,
action_url=action_url,
due_at=due_at,
assignments=_assignments(step),
sources=(
WorkSourceRef(
module_id=row.subject_module,
resource_type=row.subject_type,
resource_id=row.subject_id,
revision=row.subject_version,
),
WorkSourceRef(
module_id="approvals",
resource_type="approval_request",
resource_id=row.request_id,
revision=str(row.revision),
url=action_url,
label=title,
),
),
provenance={
"subject_digest": row.subject_digest,
"policy_refs": list(payload.get("policy_refs") or ()),
"template": payload.get("template"),
},
metadata={
"current_step_key": row.current_step_key,
"signature_required": bool(step.get("signature_required")),
"approval_state": row.state,
},
revision=str(row.revision),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _assignments(step: Mapping[str, object]) -> tuple[WorkAssignmentRef, ...]:
assignments: list[WorkAssignmentRef] = []
for raw in step.get("selectors") or ():
if not isinstance(raw, Mapping):
continue
kind = str(raw.get("kind") or "").strip()
assignment_id = str(raw.get("value") or "").strip()
if kind == "any_account":
kind, assignment_id = "anyone", "*"
if kind not in {"account", "group", "role", "function_assignment"}:
if kind != "anyone":
continue
label = str(raw.get("label") or "").strip()[:500] or None
try:
assignments.append(
WorkAssignmentRef(
kind=kind, # type: ignore[arg-type]
id=assignment_id,
label=label,
)
)
except ValueError:
continue
return tuple(assignments)
def _date(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _search_text(item: WorkItem) -> str:
return " ".join(
value for value in (item.title, item.summary, item.required_action) if value
).casefold()
def _sort_key(item: WorkItem) -> tuple[object, ...]:
priority = {"urgent": 0, "high": 1, "normal": 2, "low": 3}[item.priority]
due_at = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC)
return priority, due_at, item.id
__all__ = ["ApprovalWorkItemProvider", "PROVIDER_ID"]
+458
View File
@@ -0,0 +1,458 @@
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.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
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
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
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_work_provider_reuses_exact_decision_eligibility(self) -> None:
with self.Session() as session:
created = self.service.create_request(
session,
self.requester,
command=request_command(),
idempotency_key="work-provider-request",
)
reviewer = ApiPrincipal(
principal=PrincipalRef(
account_id="reviewer",
membership_id="membership-reviewer",
tenant_id="tenant-1",
scopes=frozenset(
{
"approvals:workspace:read",
"approvals:workspace:decide",
}
),
group_ids=frozenset({"reviewers"}),
),
account=object(),
user=object(),
)
provider = ApprovalWorkItemProvider()
page = provider.list_items(
session,
reviewer,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(1, page.total)
self.assertEqual(created.id, page.items[0].id)
self.assertEqual("Review", page.items[0].required_action)
self.assertEqual("campaign_version", page.items[0].sources[0].resource_type)
self.service.decide(
session,
reviewer,
request_id=created.id,
command=ApprovalDecisionCommand(
"approved",
"Reviewed.",
1,
"work-provider-decision",
),
)
self.assertEqual(
0,
provider.list_items(
session,
reviewer,
query=WorkItemQuery(tenant_id="tenant-1"),
).total,
)
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()
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalTemplateRevision,
)
from govoplan_approvals.backend.dsar_provider import (
APPROVALS_DSAR_CAPABILITY,
ApprovalsDsarProvider,
)
from govoplan_approvals.backend.manifest import manifest
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
NOW = datetime(2026, 8, 21, 13, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: ApprovalsDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (APPROVALS_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != APPROVALS_DSAR_CAPABILITY:
raise KeyError(name)
return "approvals"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("approvals",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != APPROVALS_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "approvals"})(),)
class ApprovalsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = ApprovalsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
ApprovalRequestRevision(
id="request-revision-1",
tenant_id="tenant-1",
request_id="request-1",
revision=1,
state="pending",
current_step_key="legal",
subject_module="cases",
subject_type="case",
subject_id="case-1",
subject_version="4",
subject_digest="subject-digest-do-not-export",
recorded_at=NOW,
payload={"secret": "request-payload-do-not-export"},
actor_id="account-1",
),
ApprovalRequestRevision(
id="request-revision-other",
tenant_id="tenant-1",
request_id="request-other",
revision=1,
state="pending",
current_step_key="legal",
subject_module="cases",
subject_type="case",
subject_id="case-other",
subject_digest="other-digest",
recorded_at=NOW,
payload={"private": "other-request"},
actor_id="account-other",
),
ApprovalRequestRevision(
id="request-revision-other-tenant",
tenant_id="tenant-2",
request_id="request-other-tenant",
revision=1,
state="pending",
subject_module="cases",
subject_type="case",
subject_id="case-other-tenant",
subject_digest="other-tenant-digest",
recorded_at=NOW,
payload={"private": "other-tenant-request"},
actor_id="account-1",
),
)
)
self.session.add(
ApprovalDecisionRecord(
id="decision-1",
tenant_id="tenant-1",
request_id="request-1",
request_revision=1,
step_key="legal",
outcome="approved",
reason="I verified the resident evidence.",
actor_id="account-1",
effective_actor_id="account-1",
delegation_id="delegation-1",
authority_provenance={
"secret": "authority-provenance-do-not-export"
},
signature_ref={"secret": "signature-object-do-not-export"},
recorded_at=NOW,
idempotency_key="decision-idempotency-do-not-export",
receipt_sha256="receipt-hash-do-not-export",
)
)
self.session.add(
ApprovalLifecycleEvent(
id="event-1",
tenant_id="tenant-1",
request_id="request-1",
sequence=1,
event_type="request.created",
recorded_at=NOW,
actor_id="account-1",
payload={"secret": "event-payload-do-not-export"},
)
)
self.session.add(
ApprovalTemplateRevision(
id="template-revision-1",
tenant_id="tenant-1",
template_id="template-1",
key="resident-permit",
revision=1,
state="published",
content_sha256="template-hash-do-not-export",
recorded_at=NOW,
payload={"secret": "template-payload-do-not-export"},
actor_id="account-1",
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_decision_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"approval_decision_participation",
"approval_request_actor_attribution",
"approval_lifecycle_actor_attribution",
"approval_template_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("I verified the resident evidence.", exported)
self.assertIn("case-1", exported)
for excluded in (
"request-payload-do-not-export",
"subject-digest-do-not-export",
"authority-provenance-do-not-export",
"signature-object-do-not-export",
"decision-idempotency-do-not-export",
"receipt-hash-do-not-export",
"event-payload-do-not-export",
"template-payload-do-not-export",
"other-request",
"other-tenant-request",
):
self.assertNotIn(excluded, exported)
def test_request_narrowing_and_conflicting_actor_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"approvals.request": "request-1"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"approvals.actor": "account-other"},
),
)
request_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"approvals.request": "request-1"}
),
)
self.assertEqual(3, len(narrowed))
self.assertNotIn(
"approval_template_actor_attribution",
{record.resource_type for record in narrowed},
)
self.assertEqual((), conflict)
self.assertEqual((), request_only)
def test_erasure_preserves_chain_and_requires_reason_review(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
records=records,
)
self.assertEqual(
{"manual_review", "retain"}, {action.kind for action in actions}
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-approvals-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertEqual(1, self.session.query(ApprovalDecisionRecord).count())
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(APPROVALS_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-APPROVALS-1",
request_kind="access",
subject=self._subject(),
purpose="Approval participation access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(4, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
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"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
self.assertEqual(4, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in topics:
if topic.metadata.get("kind") == "workflow" and "user" in topic.documentation_types:
self.assertTrue(topic.conditions, topic.id)
if __name__ == "__main__":
unittest.main()
+21 -8
View File
@@ -2,22 +2,35 @@ 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)
self.assertEqual("approvals.pending", manifest.work_item_providers[0].id)
if __name__ == "__main__": if __name__ == "__main__":
+45
View File
@@ -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()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/approvals-webui",
"version": "0.1.21",
"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.18",
"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",
"test:workspace-layout": "node tests/workspace-layout-ui-structure.test.mjs"
}
}
+122
View File
@@ -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 { FormGrid, 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>}
<FormGrid columns={2} gap="compact" collapseAt="workspace">
<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 })} />
</FormGrid>
<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 { FormGrid, ActionToolbar,
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">
<FormGrid columns={2} gap="compact" collapseAt="workspace">
<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 })} />
</FormGrid>
<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>
<ActionToolbar 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>
</ActionToolbar>
{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,106 @@
import { AlarmClock, Check, Plus, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, LoadingIndicator, MetricCard, MetricGrid, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceLayout, 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"><WorkspaceLayout
variant="split"
primarySize="compact"
primaryScrollable={false}
contentScrollable={false}
surface="contained"
className="approvals-shell"
primaryClassName="approvals-list-panel"
contentClassName="approvals-workspace"
primaryLabel="Approval requests"
contentLabel="Approval request details"
interfaceId="approvals.workspace"
helpContextId="approvals.workspace"
helpModuleId="approvals"
primary={<>
<WorkspaceActionBar scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "Refresh approvals" }} className="approvals-toolbar" createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>} helpAction={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} />
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<SelectionList label="Approval requests" variant="navigation">{items.map((item) => <SelectionListItem key={item.id} selected={item.id === selectedId} onClick={() => setSelectedId(item.id)}><SelectionListItemContent title={item.title} description={`${item.subject_module} / ${item.subject_type}`} /><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>{!loading && items.length === 0 && <StatePanel size="compact" description="No approval requests" />}</PageScrollViewport>
</>}
>
{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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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>
<MetricGrid columns={4} spacing="block"><MetricCard density="compact" label="Revision" value={selected.revision} /><MetricCard density="compact" label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><MetricCard density="compact" label="Requested by" value={selected.requested_by || "-"} /><MetricCard density="compact" label="Steps" value={selected.steps.length} /></MetricGrid>
{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 && <StatePanel size="fill" title="Approval requests" description="Select or create an approval request." />}
</WorkspaceLayout>
{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 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;
+129
View File
@@ -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 };
+2
View File
@@ -0,0 +1,2 @@
export { default, approvalsModule } from "./module";
export * from "./api/approvals";
+47
View File
@@ -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.19",
dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"],
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;
+37
View File
@@ -0,0 +1,37 @@
.approvals-page { height: 100%; min-height: 0; overflow: hidden; }
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; }
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
.approvals-detail header span { color: var(--muted); }
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; }
.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(--line); }
.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; }
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
.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); }
.approval-chain > div.is-current { border-left: 3px solid var(--data-category-blue); }
.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(--muted); 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-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(--line); border-radius: var(--radius-sm); }
.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(--line); }
.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: 900px) { .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.");
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/approvals.css", "utf8");
assert.ok(page.includes("<WorkspaceLayout"), "Approvals must use the shared workspace shell");
assert.ok(page.includes('variant="split"'), "Approvals must use the shared split-pane geometry");
assert.ok(page.includes('primarySize="compact"'), "The request list uses the bounded compact pane width");
assert.ok(page.includes("primaryScrollable={false}"), "The request list delegates scrolling to its contained viewport");
assert.ok(page.includes("contentScrollable={false}"), "The detail pane delegates scrolling to its contained viewport");
assert.ok(!styles.includes(".approvals-shell { display: grid"), "Approvals must not redefine the shared split-pane grid");
console.log("Approval workspace layout structural contract passed.");