23 Commits
Author SHA1 Message Date
zemion 1f61464fd6 fix(packaging): expose immutable WebUI Git package for v0.1.22
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:11 +02:00
zemion 19cbdd303b Release govoplan-templates v0.1.22: bound rendering and improve dialog layout 2026-09-08 01:32:53 +02:00
zemion 69416faca9 fix(webui): bind template lifecycle controls to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:45 +02:00
zemion ee6fbc784f docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:41 +02:00
zemion dff2508698 docs(templates): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:27:26 +02:00
zemion 80001bbe98 feat(templates): add governed DSAR coverage 2026-08-21 13:24:13 +02:00
zemion 5de54ccd2e feat(templates): persist reusable content requirements 2026-08-19 21:54:54 +02:00
zemion 1c12359750 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 58be2482ec feat: align templates with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion 3856765520 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion cf4205f780 Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion 21ed6270a3 feat: add reusable content fragments 2026-08-07 14:53:34 +02:00
zemion 6f8a70f864 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:52 +02:00
zemion 378ad9ec83 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:17 +02:00
zemion 7c661384d9 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:32 +02:00
zemion 95a443f359 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:28 +02:00
zemion 0dcaa90abd Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 21f3a6cd38 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 79f70a2492 Add protected package release workflow 2026-08-04 04:14:08 +02:00
zemion 72fafa23c7 Migrate Templates interface patterns 2026-08-03 13:34:37 +02:00
zemion 3551c48e14 Restrict rendered template artifacts 2026-08-02 13:58:45 +02:00
zemion b65b905b6e Implement typed template library and rendering 2026-08-02 12:38:38 +02:00
zemion 142c3a26f1 Release v0.1.8 2026-07-11 16:49:05 +02:00
39 changed files with 6309 additions and 104 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
+47 -3
View File
@@ -1,12 +1,56 @@
# GovOPlaN Templates
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-templates` owns reusable renderable template definitions and render
contracts for GovOPlaN. It is intentionally separate from reporting, DMS/files,
mail delivery, forms runtime, and workflow state.
This repository is currently a tag-only scaffold. It should gain package
metadata and module manifests only after the first backend or WebUI slice is
designed.
The first operational slice provides:
- a scoped, versioned library for labels, label sheets, envelopes, serial and
form letters, list layouts, email, and generic templates;
- explicit usages, locales, required-field contracts, output profiles, and
compatibility diagnostics;
- safe deterministic HTML/text rendering from frozen caller-owned snapshots;
- immutable template/input/output hashes, renderer version, item/page counts,
diagnostics, and idempotent final-output evidence;
- optional managed artifact persistence through the Core Files contract, with
an actor-scoped bounded Templates download when Files is absent; and
- a full-height library/editor/preview WebUI using the shared rich-text editor.
The module has no hard dependency on its consumers or on Files.
## Development
```bash
cd /mnt/DATA/git/govoplan-templates
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:$PATH tsc -p webui/tsconfig.json --noEmit
```
See [docs/TEMPLATE_BOUNDARY.md](docs/TEMPLATE_BOUNDARY.md) for the boundary
decision.
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
## Git-source WebUI package
The repository root exposes `@govoplan/templates-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/templates-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.
+38
View File
@@ -0,0 +1,38 @@
# Templates Administrator Guide
## Permissions
- `templates:template:read` reads definitions and evidence.
- `templates:template:write` creates immutable revisions.
- `templates:template:publish` selects the revision allowed for final output.
- `templates:template:render` validates and renders supplied snapshots.
- `templates:template:admin` manages every visible tenant/group/user definition.
The managed `template_manager` role contains read, write, publish, and render.
## Scope And Publication
Definitions can be tenant-, group-, or user-scoped. Non-administrators may only
write their own user templates and templates belonging to one of their groups.
Published output remains pinned even when a later draft revision is created.
## Output Storage
Files is optional. When `files.artifact_store` is present and the actor has
`files:file:upload`, managed output is written below `Generated/Templates` with
template, input, and output hashes. Otherwise Templates stores a bounded
database payload. Review database and Files retention together before deleting
render evidence.
Without Files, output payloads are bounded and retained by Templates. Ordinary
users can list and download only output they rendered themselves; a principal
with `templates:template:admin` can inspect all tenant render evidence. Consumer
modules must not redistribute the Templates download URL directly when their
resource access rules differ.
## Operations
Apply the module Alembic migration before startup. Monitor rejected renders for
contract drift, output limits, missing Files permission, and reused idempotency
keys. HTML is designed for browser/OS printing; do not treat it as a signed PDF
or proof of physical printer delivery.
+32
View File
@@ -0,0 +1,32 @@
# Templates Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the Template
library, immutable-revision editor, preview/final-output workspace, and render
evidence history.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/templates` library | Governed directory | Select, create, or retire Template | Shared loading, empty, permission, read-only, disabled-reason, and help states |
| Definition editor | Consequential definition editor | Save immutable revision | Guarded draft with scope, usage, required-data, layout, and content semantics |
| Publish action | Governed lifecycle transition | Make one revision consumable | Permission/lifecycle explanation and explicit confirmation |
| Preview/final output | Evidence-producing preview | Validate sample or render final output | Compatibility diagnostics, published-revision gate, confirmation, and retained hashes |
| Revision/render history | Evidence register | Inspect immutable history | Stable status, timestamps, digests, artifact availability, and bounded download |
## Consequence And Availability Rules
- Saving creates a new immutable revision. Publishing never rewrites an older
revision or its render evidence.
- Inherited or policy-constrained Templates remain visible but identify why
they are read-only, who can change that, and where to continue.
- Final output requires a published revision and explicit confirmation. It
records template, input, output, and renderer evidence and may use Files only
through the optional artifact capability.
- Deleting prevents future selection while retained revisions and renders stay
governed by retention policy.
Backend and WebUI manifests publish the same surface identifiers. English and
German catalogues cover module-owned vocabulary, contextual help resolves from
manifest documentation, and create/editor drafts are guarded across selection,
reload, and navigation.
+45 -101
View File
@@ -1,113 +1,57 @@
# Template Module Boundary
`govoplan-templates` owns reusable renderable templates, not the data selection
or persistence semantics around the generated output.
`govoplan-templates` owns reusable render definitions and immutable render
evidence. Callers own data selection, approval, delivery, and lifecycle state.
The core boundary decision register is in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
## Owned Concepts
## Ownership
- scoped template definitions and immutable revisions;
- template type, usage, locale, required-field contracts, output profiles, and
page/media hints;
- safe HTML/text bodies and deterministic token substitution;
- draft preview and published final rendering;
- template, input, renderer, and output hashes plus item/page counts and
diagnostics; and
- bounded fallback payloads when no artifact store is available.
Templates owns:
The implemented printable types are `label`, `label_sheet`, `envelope`,
`serial_letter`, `form_letter`, and `list_layout`. `email` and `generic` use the
same contract while preserving their explicit usage.
- template definitions for letters, decisions, permits, emails, forms, reports,
certificates, notices, and workflow messages
- template versions, draft/published lifecycle, localization, and merge-field
declarations
- render profiles such as output format, page/layout hints, fallback language,
and safe preview mode
- render-context schema declarations so callers know which fields are required
- reusable template fragments inside configuration packages
- rendering capability contracts exposed to mail, campaign, reporting, forms,
workflow, cases, DMS, and files
## Consumer Boundary
## Boundaries
Templates never imports Addresses, Distribution Lists, Campaign, Files, Mail,
Reporting, Forms, or Workflow internals. Consumers discover
`templates.catalog` and `templates.renderer` through Core and submit plain
provider-neutral DTOs. The supplied `input_snapshot` records a stable source
reference; Templates does not fetch or silently refresh that source.
Templates does not own:
Files optionally implements `files.artifact_store`. A final render can request
managed persistence through that contract. If Files is absent, incompatible,
or unauthorized, the result carries a warning and remains available through a
5 MiB actor-scoped Templates download. Managed output is not duplicated in the
Templates payload column.
- report data selection, aggregation, dashboards, scheduled exports, or BI
semantics; those belong to `govoplan-reporting`
- document lifecycle, collaborative editing, locks, approvals, legal hold, or
records management; those belong to `govoplan-dms` and `govoplan-records`
- file/blob storage and file permissions; those belong to `govoplan-files`
- mail sending, mailbox behavior, and mail profile policy; those belong to
`govoplan-mail`
- form submissions, drafts, receipts, and public submission state; those belong
to `govoplan-forms-runtime` when implemented
- workflow transitions, tasks, and case lifecycle
## Safety And Determinism
## Initial Template Types
- backend sanitization removes scripts, styles, active embeds, unsafe links,
event handlers, and undeclared attributes;
- substituted values are HTML escaped;
- output is limited to 5,000 items and 5 MiB;
- final output requires a published revision and idempotency key;
- reusing an idempotency key with changed input is rejected;
- every render pins the immutable definition hash and canonical input hash;
- final artifacts contain hashes and references, not credentials or plaintext
secrets in provenance; and
- browser/OS printing from deterministic HTML is the baseline. PDF conversion
and managed printer delivery belong to future connector adapters.
- `letter`
- `decision_document`
- `permit`
- `email`
- `form`
- `report`
- `certificate`
- `notice`
- `workflow_message`
## Recovery
Template types can share a render engine but should keep type-specific metadata
explicit, especially when retention, signature, accessibility, or delivery
rules differ.
## Render Context Contract
Candidate render request:
```json
{
"template_id": "permit-decision",
"template_version_id": "v1",
"template_type": "permit",
"locale": "de-DE",
"output_format": "pdf",
"context": {
"case_id": "case-1",
"recipient": {"display_name": "Example Person"},
"decision": {"approved": true}
},
"trace": {"correlation_id": "request-1"}
}
```
Candidate render response:
```json
{
"render_id": "render-1",
"template_id": "permit-decision",
"template_version_id": "v1",
"output_format": "pdf",
"artifact": {
"content_type": "application/pdf",
"storage_ref": "files://generated/render-1.pdf",
"checksum": "sha256:..."
},
"warnings": []
}
```
Generated artifact storage can be delegated to files/DMS through capabilities.
Templates should not import those modules directly.
## Candidate Capabilities
- `templates.catalog`
- `templates.renderer`
- `templates.preview`
- `templates.schema`
- `templates.packageFragments`
Consumers should request these through core-mediated capability lookup. The
template module should not import consumer modules.
## First Implementation Slice
1. Define manifest metadata, permissions, and capability names.
2. Add template definition/version DTOs.
3. Add render-context schema validation for one safe text/PDF preview path.
4. Add package fragment format for reusable templates.
5. Add tests that mail/campaign/reporting/forms can detect template capability
presence without importing template internals.
Template definitions, revisions, render evidence, and bounded output are in the
shared database and therefore follow platform backup and restore. Managed Files
artifacts follow Files recovery. Bounded render payloads and history are visible
only to their creator or a Templates administrator; consumers provide a
resource-governed proxy when collaborators need access. Retiring the module is
destructive only after the installer captures a database snapshot; consumers
retain pinned hashes and must diagnose the now-unavailable provider.
+23
View File
@@ -0,0 +1,23 @@
# Templates User Guide
Open **Templates** to create or select a reusable definition.
1. Choose the template type and the contexts in which it may be used, such as
`campaign.postal`.
2. Declare every required input path and its type. Use the same paths as tokens
in the body, for example `{{name}}` or `{{postal.address}}`.
3. Configure page size and, for label sheets, rows, columns, and spacing.
4. Save to create a new immutable revision. Publish the revision before using
it for final output.
5. In **Preview**, supply a representative JSON item. Compatibility validation
explains missing fields, wrong types, unsupported usages, and output-format
mismatches before output is produced.
6. Preview a draft or render final output. Store it in Files when that module is
available and you have upload permission; otherwise use the bounded download.
Bounded output history is visible only to the actor who rendered it and to a
Templates administrator. Calling modules expose their own governed download
when additional collaborators need access.
Render evidence shows the exact revision and abbreviated template, input, and
output hashes. A consumer such as Campaign can submit many frozen recipients;
the UI sample intentionally validates one representative item.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@govoplan/templates-webui",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/templates.css": "./webui/src/styles/templates.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-templates"
version = "0.1.22"
description = "GovOPlaN typed template library and deterministic printable rendering."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.45",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_templates = ["py.typed"]
[project.entry-points."govoplan.modules"]
templates = "govoplan_templates.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Templates module."""
__version__ = "0.1.22"
@@ -0,0 +1 @@
"""Backend implementation for GovOPlaN Templates."""
@@ -0,0 +1,258 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.templates import (
TemplateCatalogProvider,
TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef,
)
from govoplan_templates.backend.rendering import SqlTemplateRenderer
from govoplan_templates.backend.schemas import TemplateCreateRequest
from govoplan_templates.backend.service import (
READ_SCOPE,
WRITE_SCOPE,
compatibility,
create_template,
get_template,
get_template_revision,
list_templates,
template_ref,
)
class SqlTemplateCatalog(TemplateCatalogProvider):
def list_templates(
self,
session: object,
principal: object,
*,
query: str = "",
usage: str | None = None,
template_type: str | None = None,
locale: str | None = None,
limit: int = 100,
) -> Sequence[TemplateRef]:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
rows = list_templates(
sql_session,
api_principal,
query=query,
usage=usage,
template_type=template_type,
locale=locale,
limit=limit,
)
return tuple(
template_ref(
row,
get_template_revision(sql_session, row, published_preferred=True),
read_only=_read_only(api_principal, row.scope_type, row.scope_id),
)
for row in rows
)
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
) -> TemplateRef | None:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
try:
row = get_template(sql_session, api_principal, template_id)
item_revision = get_template_revision(
sql_session,
row,
revision=revision,
published_preferred=revision is None,
)
except ValueError:
return None
return template_ref(
row,
item_revision,
read_only=_read_only(api_principal, row.scope_type, row.scope_id),
)
def check_compatibility(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
usage: str | None = None,
output_format: str | None = None,
available_fields: Mapping[str, str] | Sequence[str] = (),
) -> TemplateCompatibility:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
row = get_template(sql_session, api_principal, template_id)
item_revision = get_template_revision(
sql_session,
row,
revision=revision,
published_preferred=revision is None,
)
return compatibility(
item_revision,
usage=usage,
output_format=output_format,
available_fields=available_fields,
)
class SqlTemplateContentLibrary(TemplateContentLibraryProvider):
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef:
sql_session, api_principal = _context(session, principal)
_require_write(api_principal)
if request.template_type not in {"content_fragment", "email", "generic"}:
raise ValueError(
"Reusable content drafts must be a content fragment, email, or generic template."
)
item, revision = create_template(
sql_session,
api_principal,
TemplateCreateRequest(
name=request.name,
description=request.description,
scope_type=request.scope_type,
scope_id=request.scope_id,
template_type=request.template_type,
usages=list(request.usages),
locale=request.locale,
required_fields=[
{
"path": field.path,
"value_type": field.value_type,
"label": field.label,
"required": field.required,
"description": field.description,
}
for field in request.required_fields
],
output_profiles=[],
content_text=request.content_text,
content_html=request.content_html,
layout={},
metadata={
**dict(request.metadata),
"created_through": "templates.content_library",
},
),
)
audit_from_principal(
sql_session,
api_principal,
action="templates.template.created",
object_type="template",
object_id=item.id,
details={
"revision": revision.revision,
"definition_hash": revision.definition_hash,
"template_type": revision.template_type,
"usages": list(revision.usages or []),
"required_fields": [
str(field.get("path") or "")
for field in revision.required_fields or []
if field.get("path")
],
"source": "content_library_capability",
},
commit=False,
)
emit_platform_event(
sql_session,
PlatformEvent(
type="templates.template.created.v1",
module_id="templates",
actor=EventActorRef(type="account", id=api_principal.account_id),
tenant=EventTenantRef(id=api_principal.tenant_id),
resource=EventObjectRef(type="template", id=item.id),
classification="internal",
),
)
return template_ref(item, revision, read_only=False)
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
return SqlTemplateCatalog()
def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
return SqlTemplateRenderer(context.registry)
def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary:
return SqlTemplateContentLibrary()
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
if not isinstance(session, Session):
raise TypeError("Template catalogue access requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Template catalogue access requires an API principal.")
return session, principal
def _require_read(principal: ApiPrincipal) -> None:
if not any(
principal.has(scope)
for scope in (
READ_SCOPE,
"templates:template:write",
"templates:template:publish",
"templates:template:admin",
)
):
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.")
def _require_write(principal: ApiPrincipal) -> None:
if not any(
principal.has(scope)
for scope in (WRITE_SCOPE, "templates:template:admin")
):
raise PermissionError(f"Template draft creation requires {WRITE_SCOPE}.")
def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
if principal.has("templates:template:admin") or scope_type == "tenant":
return False
if scope_type == "user":
return scope_id != principal.account_id
return scope_id not in principal.group_ids
__all__ = [
"SqlTemplateCatalog",
"SqlTemplateContentLibrary",
"catalog_capability",
"content_library_capability",
"renderer_capability",
]
@@ -0,0 +1,7 @@
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
__all__ = ["TemplateDefinition", "TemplateRender", "TemplateRevision"]
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
LargeBinary,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class TemplateDefinition(Base, TimestampMixin):
__tablename__ = "template_definitions"
__table_args__ = (
Index(
"ix_template_definitions_tenant_status",
"tenant_id",
"status",
"updated_at",
),
Index(
"uq_template_definitions_active_tenant_slug",
"tenant_id",
"slug",
unique=True,
sqlite_where=text("deleted_at IS NULL AND scope_id IS NULL"),
postgresql_where=text("deleted_at IS NULL AND scope_id IS NULL"),
),
Index(
"uq_template_definitions_active_named_scope_slug",
"tenant_id",
"scope_type",
"scope_id",
"slug",
unique=True,
sqlite_where=text("deleted_at IS NULL AND scope_id IS NOT NULL"),
postgresql_where=text("deleted_at IS NULL AND scope_id IS NOT NULL"),
),
)
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)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(300), nullable=False)
slug: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
template_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False, index=True)
current_revision_id: Mapped[str] = mapped_column(String(36), nullable=False)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
published_revision_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
revisions: Mapped[list["TemplateRevision"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TemplateRevision.revision",
)
renders: Mapped[list["TemplateRender"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TemplateRender.created_at",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag("template_definition", self.id, self.resource_revision)
class TemplateRevision(Base, TimestampMixin):
__tablename__ = "template_revisions"
__table_args__ = (
UniqueConstraint("template_id", "revision", name="uq_template_revision_number"),
Index("ix_template_revisions_tenant_template", "tenant_id", "template_id"),
Index("ix_template_revisions_hash", "tenant_id", "definition_hash"),
)
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(
ForeignKey("template_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
definition_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
template_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
usages: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
locale: Mapped[str] = mapped_column(String(35), default="en", nullable=False, index=True)
required_fields: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
output_profiles: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
content_text: Mapped[str | None] = mapped_column(Text, nullable=True)
content_html: Mapped[str | None] = mapped_column(Text, nullable=True)
layout: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
published_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
definition: Mapped[TemplateDefinition] = relationship(back_populates="revisions")
renders: Mapped[list["TemplateRender"]] = relationship(back_populates="revision")
class TemplateRender(Base, TimestampMixin):
__tablename__ = "template_renders"
__table_args__ = (
UniqueConstraint("tenant_id", "idempotency_key", name="uq_template_render_idempotency"),
Index("ix_template_renders_tenant_template", "tenant_id", "template_id", "created_at"),
Index("ix_template_renders_input_hash", "tenant_id", "input_hash"),
)
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(
ForeignKey("template_definitions.id", ondelete="CASCADE"), nullable=False, index=True
)
revision_id: Mapped[str] = mapped_column(
ForeignKey("template_revisions.id", ondelete="RESTRICT"), nullable=False, index=True
)
revision_number: Mapped[int] = mapped_column(Integer, nullable=False)
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
usage: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
output_format: Mapped[str] = mapped_column(String(20), nullable=False)
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
filename: Mapped[str] = mapped_column(String(500), nullable=False)
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
template_hash: Mapped[str] = mapped_column(String(64), nullable=False)
input_hash: Mapped[str] = mapped_column(String(64), nullable=False)
renderer_version: Mapped[str] = mapped_column(String(40), nullable=False)
output_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
output_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
item_count: Mapped[int] = mapped_column(Integer, nullable=False)
page_count: Mapped[int] = mapped_column(Integer, nullable=False)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
input_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
artifact_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
payload: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
definition: Mapped[TemplateDefinition] = relationship(back_populates="renders")
revision: Mapped[TemplateRevision] = relationship(back_populates="renders")
@@ -0,0 +1,377 @@
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_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
TEMPLATES_DSAR_CAPABILITY = dsar_capability_name("templates")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str
template_id: str | None
revision_id: str | None
render_id: str | None
@property
def narrowed(self) -> bool:
return bool(self.template_id or self.revision_id or self.render_id)
class TemplatesDsarProvider:
provider_id = "templates"
module_id = "templates"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if not selectors.narrowed or selectors.template_id:
query = db.query(TemplateDefinition).filter(
TemplateDefinition.tenant_id == tenant_id,
or_(
TemplateDefinition.created_by_account_id == selectors.account_id,
TemplateDefinition.updated_by_account_id == selectors.account_id,
),
)
if selectors.template_id:
query = query.filter(TemplateDefinition.id == selectors.template_id)
records.extend(
_definition_record(row, selectors.account_id)
for row in _limited(
query,
TemplateDefinition.created_at,
TemplateDefinition.id,
label="definition attribution",
)
)
if not selectors.narrowed or selectors.template_id or selectors.revision_id:
query = db.query(TemplateRevision).filter(
TemplateRevision.tenant_id == tenant_id,
or_(
TemplateRevision.created_by_account_id == selectors.account_id,
TemplateRevision.published_by_account_id == selectors.account_id,
),
)
if selectors.template_id:
query = query.filter(
TemplateRevision.template_id == selectors.template_id
)
if selectors.revision_id:
query = query.filter(TemplateRevision.id == selectors.revision_id)
records.extend(
_revision_record(row, selectors.account_id)
for row in _limited(
query,
TemplateRevision.created_at,
TemplateRevision.id,
label="revision attribution",
)
)
if not selectors.narrowed or selectors.template_id or selectors.render_id:
query = db.query(TemplateRender).filter(
TemplateRender.tenant_id == tenant_id,
TemplateRender.created_by_account_id == selectors.account_id,
)
if selectors.template_id:
query = query.filter(
TemplateRender.template_id == selectors.template_id
)
if selectors.render_id:
query = query.filter(TemplateRender.id == selectors.render_id)
records.extend(
_render_record(row)
for row in _limited(
query,
TemplateRender.created_at,
TemplateRender.id,
label="render attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Templates DSAR result limit exceeded; narrow 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 _selectors(subject) is None:
raise ValueError("Templates DSAR subject selectors conflict.")
actions = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"templates:retain:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=(
record.retention_reason
or "Template lifecycle attribution remains evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Templates DSAR subject selectors conflict.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Templates DSAR publishes retain actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Template lifecycle attribution remains evidence.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("templates.account"),
references.get("access.account"),
),
"template_id": _coalesce(
references.get("templates.template"),
references.get("templates.template_id"),
),
"revision_id": _coalesce(
references.get("templates.revision"),
references.get("templates.revision_id"),
),
"render_id": _coalesce(
references.get("templates.render"), references.get("templates.render_id")
),
}
if any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional(values["account_id"])
if not account_id:
return None
return _Selectors(
account_id=account_id,
template_id=_optional(values["template_id"]),
revision_id=_optional(values["revision_id"]),
render_id=_optional(values["render_id"]),
)
def _definition_record(row: TemplateDefinition, account_id: str) -> DsarRecordRef:
activities = []
if row.created_by_account_id == account_id:
activities.append("created_template")
if row.updated_by_account_id == account_id:
activities.append("updated_template")
return _record(
resource_type="template_definition_actor_attribution",
resource_id=row.id,
title="Template-definition actor attribution",
data={
"template_id": row.id,
"template_type": row.template_type,
"scope_type": row.scope_type,
"status": row.status,
"current_revision": row.current_revision,
"resource_revision": row.resource_revision,
"activities": activities,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
"retired_at": _iso(row.deleted_at),
},
observed_at=row.updated_at,
)
def _revision_record(row: TemplateRevision, account_id: str) -> DsarRecordRef:
activities = []
if row.created_by_account_id == account_id:
activities.append("created_template_revision")
if row.published_by_account_id == account_id:
activities.append("published_template_revision")
return _record(
resource_type="template_revision_actor_attribution",
resource_id=row.id,
title="Template-revision actor attribution",
data={
"template_id": row.template_id,
"revision_id": row.id,
"revision": row.revision,
"template_type": row.template_type,
"locale": row.locale,
"activities": activities,
"created_at": _iso(row.created_at),
"published_at": _iso(row.published_at),
},
observed_at=row.published_at or row.created_at,
)
def _render_record(row: TemplateRender) -> DsarRecordRef:
return _record(
resource_type="template_render_actor_attribution",
resource_id=row.id,
title="Template-render actor attribution",
data={
"template_id": row.template_id,
"revision_id": row.revision_id,
"render_id": row.id,
"revision": row.revision_number,
"mode": row.mode,
"usage": row.usage,
"output_format": row.output_format,
"content_type": row.content_type,
"item_count": row.item_count,
"page_count": row.page_count,
"output_size_bytes": row.output_size_bytes,
"activity": "rendered_template",
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
)
def _record(
*,
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="templates",
module_id="templates",
resource_type=resource_type,
resource_id=resource_id,
category="template_governance_attribution",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Template definition, publication, and render attribution is retained "
"with immutable lifecycle 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"Templates 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(value: object) -> str | None:
return value if isinstance(value, str) and value 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("Templates DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"template_definition_actor_attribution",
"template_revision_actor_attribution",
"template_render_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "templates" or record.module_id != "templates":
raise ValueError("Templates DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Templates DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "templates" or action.module_id != "templates":
raise ValueError("Templates DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("templates:retain:"):
raise ValueError("Templates DSAR action identity is invalid.")
__all__ = ["TEMPLATES_DSAR_CAPABILITY", "TemplatesDsarProvider"]
@@ -0,0 +1,58 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'templates.data-subject-requests': {'consequence_classes': {'exclude_template_payloads': 'Gibt '
'keinen '
'Template-Inhalt '
'zurück '
'oder '
'rendert '
'Inputs '
'und '
'Outputs.',
'export_template_attribution': 'Returns '
'minimiert '
'Autor, '
'Publikation '
'und '
'Render-Aktivität.'}},
'templates.reference.fields-and-consequences': {'consequence_classes': {'delete_template': 'Verhindert '
'zukünftige '
'Auswahl, '
'ohne '
'beibehaltene '
'Revisionen '
'neu '
'zu '
'schreiben '
'oder '
'Nachweise '
'zu '
'erbringen.',
'publish_revision': 'Macht '
'die '
'ausgewählte '
'unveränderliche '
'Revision '
'für '
'die '
'Endverbraucherproduktion '
'geeignet.',
'render_final': 'Erstellt '
'gespeicherte '
'Render-Nachweise '
'und kann '
'ein '
'Artefakt '
'durch '
'Dateien '
'beibehalten.',
'save_revision': 'Erstellt '
'eine '
'neue '
'unveränderliche '
'Template-Revision.'}}}
+579
View File
@@ -0,0 +1,579 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_templates.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_templates.backend.db import models as template_models
from govoplan_templates.backend.dsar_provider import (
TEMPLATES_DSAR_CAPABILITY,
TemplatesDsarProvider,
)
MODULE_ID = "templates"
MODULE_NAME = "Templates"
MODULE_VERSION = "0.1.22"
READ_SCOPE = "templates:template:read"
WRITE_SCOPE = "templates:template:write"
PUBLISH_SCOPE = "templates:template:publish"
RENDER_SCOPE = "templates:template:render"
ADMIN_SCOPE = "templates:template:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
READ_SCOPE,
"View templates",
"Read template definitions, revisions, and render evidence.",
),
_permission(
WRITE_SCOPE, "Manage templates", "Create and revise reusable templates."
),
_permission(
PUBLISH_SCOPE,
"Publish templates",
"Publish immutable template revisions for final output.",
),
_permission(
RENDER_SCOPE,
"Render templates",
"Preview and render governed output from supplied snapshots.",
),
_permission(
ADMIN_SCOPE,
"Administer templates",
"Manage all tenant, group, and user templates.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="template_manager",
name="Template manager",
description="Create, publish, and render reusable typed templates.",
permissions=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE),
),
)
DOCUMENTATION = (
DocumentationTopic(
id="templates.workspace-layout",
title="Templates workspace actions",
summary="Find collection-wide commands in their consistent workspace position.",
body="Reload and Add template use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Template editing, saving, publishing, and rendering remain scoped to the selected template. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Vorlagen: Aktionen im Arbeitsbereich",
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
"body": "Neu laden und Vorlage hinzufügen stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Bearbeiten, Speichern, Veröffentlichen und Rendern bleiben der ausgewählten Vorlage zugeordnet. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
}},
),
DocumentationTopic(
id="templates.library",
title="Template library",
summary="Create versioned templates with explicit usages and required data fields.",
body=(
"Templates are reusable, scoped definitions. Every edit creates an immutable revision. "
"Publish the revision that consumers may use for final output. A compatibility check explains "
"missing fields, unsupported usages, and unavailable output formats before rendering. "
"The Add template dialog uses the shared responsive form layout: name, type, and footer actions remain inside the dialog on narrow screens without horizontal form scrolling."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
conditions=(
DocumentationCondition(
required_modules=("templates",),
any_scopes=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE),
),
),
translations={
"de": {
"title": "Vorlagenbibliothek verwalten",
"summary": "Versionierte Vorlagen mit ausdrücklichen Verwendungen und erforderlichen Datenfeldern erstellen.",
"body": (
"Vorlagen sind wiederverwendbare, bereichsgebundene Definitionen. Jede Bearbeitung erzeugt eine unveränderliche Revision. "
"Veröffentlichen Sie die Revision, die Verbraucher für endgültige Ausgaben verwenden dürfen. Vor dem Rendern erläutert eine "
"Kompatibilitätsprüfung fehlende Felder, nicht unterstützte Verwendungen und nicht verfügbare Ausgabeformate. "
"Der Dialog zum Hinzufügen einer Vorlage verwendet das gemeinsame responsive Formularlayout: Name, Typ und Fußzeilenaktionen bleiben auch auf schmalen Bildschirmen ohne horizontales Formularscrollen im Dialog."
),
}
},
metadata={
"kind": "workflow",
"seed": True,
"help_contexts": [
"templates.page",
"templates.library",
"templates.editor",
"templates.state.read-only",
],
},
),
DocumentationTopic(
id="templates.reusable-content",
title="Reusable content fragments and campaign parts",
summary="Create scoped, versioned content that authorized consumers can insert without copying a private library.",
body=(
"Content fragments retain text and HTML variants, usage constraints, locale, scope, revision, and publication state in Templates. "
"Campaign can load a fragment or complete email part through the optional content-library capability. Saving from Campaign creates "
"a draft with its declared required-field contract and never publishes it automatically. Consumers show missing required fields before "
"applying content. Inserting content changes only the current Campaign draft; existing Campaign versions "
"and Template revisions remain unchanged."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "campaign_author"),
related_modules=("campaigns",),
translations={
"de": {
"title": "Wiederverwendbare Inhaltsbausteine und Kampagnenteile",
"summary": "Bereichsbezogene, versionierte Inhalte erstellen und in berechtigten Modulen verwenden.",
"body": (
"Inhaltsbausteine speichern Text- und HTML-Fassungen, Verwendungszwecke, Sprache, Geltungsbereich, Revision und "
"Veröffentlichungsstatus in Templates. Campaign kann Bausteine oder vollständige E-Mail-Teile über die optionale "
"Inhaltsbibliothek laden. Das Speichern aus Campaign legt einen Entwurf mit dem deklarierten Pflichtfeldvertrag an und "
"veröffentlicht ihn niemals automatisch. Fehlende Pflichtfelder werden vor dem Anwenden angezeigt. "
"Das Einfügen ändert nur den aktuellen Kampagnenentwurf; bestehende Kampagnenversionen und Template-Revisionen bleiben unverändert."
),
}
},
metadata={
"seed": True,
"help_contexts": [
"templates.field.type",
"templates.field.usages",
"campaign.template.content-library",
],
},
),
DocumentationTopic(
id="templates.printable-output",
title="Printable template output",
summary="Render labels, envelopes, letters, and list layouts from frozen input snapshots.",
body=(
"Preview output may use a draft revision. Final output requires a published revision and an "
"idempotency key. Results pin the template hash, input hash, renderer version, item/page counts, "
"diagnostics, and output digest. Files stores artifacts when available and authorized; otherwise "
"Templates provides a bounded download. The existing 5 MiB output ceiling is enforced while substituting tokens and composing items, "
"including UTF-8 bytes, escaping, separators, and the final HTML wrapper. Oversized expansion stops before the whole bundle is allocated "
"or any output is persisted; reduce the selected items or template content and retry. Browser printing is the supported baseline output path."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("files", "dist_lists", "campaigns", "audit"),
translations={
"de": {
"title": "Druckfähige Vorlagenausgabe",
"summary": "Etiketten, Umschläge, Briefe und Listenlayouts aus eingefrorenen Eingabe-Snapshots rendern.",
"body": (
"Eine Vorschau darf eine Entwurfsrevision verwenden. Endgültige Ausgabe verlangt eine veröffentlichte Revision und einen "
"Idempotenzschlüssel. Ergebnisse legen Vorlagenhash, Eingabehash, Renderer-Version, Element-/Seitenanzahl, Diagnosen und "
"Ausgabe-Digest fest. Files speichert Artefakte, wenn die Fähigkeit verfügbar und berechtigt ist; andernfalls stellt Templates "
"einen begrenzten Download bereit. Die bestehende Ausgabegrenze von 5 MiB wird bereits beim Ersetzen von Platzhaltern und Zusammenstellen "
"der Elemente durchgesetzt, einschließlich UTF-8-Bytes, Maskierung, Trennzeichen und abschließender HTML-Hülle. Übermäßige Erweiterung "
"stoppt vor dem vollständigen Speicheraufbau oder Speichern einer Ausgabe; reduzieren Sie die ausgewählten Elemente oder den Vorlageninhalt "
"und versuchen Sie es erneut. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
),
}
},
metadata={
"seed": True,
"help_contexts": [
"templates.preview",
"templates.action.validate-preview",
"templates.action.render-final",
"templates.evidence.render",
],
},
),
DocumentationTopic(
id="templates.reference.fields-and-consequences",
title="Template fields and lifecycle consequences",
summary="Scope, usage, data contract, publication, rendering, and deletion semantics for reusable Templates.",
body=(
"Visibility determines which tenant, group, or user scope may discover the Template; inherited Templates may be read-only. "
"Usages are capability contexts that constrain where a Template may be selected. Required fields form the compatibility "
"contract checked against supplied data before rendering. Saving creates a new immutable revision. Publishing marks one "
"revision as available for final output without rewriting older revisions or evidence. Preview validates and renders bounded "
"sample output; final rendering requires the published revision and records template, input, and output hashes plus renderer "
"evidence. Files may retain the artifact when its optional capability is available. Deletion removes the Template from future "
"selection but does not rewrite retained render evidence."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("files", "dist_lists", "campaigns", "audit", "policy"),
translations={
"de": {
"title": "Vorlagenfelder und Folgen des Lebenszyklus",
"summary": (
"Semantik von Geltungsbereich, Verwendung, Datenvertrag, Veröffentlichung, Rendering und Löschung wiederverwendbarer Vorlagen."
),
"body": (
"Die Sichtbarkeit bestimmt, in welchem Mandanten-, Gruppen- oder Benutzerbereich eine Vorlage auffindbar ist; geerbte "
"Vorlagen können schreibgeschützt sein. Verwendungen sind Fähigkeitskontexte, die begrenzen, wo eine Vorlage ausgewählt werden "
"darf. Pflichtfelder bilden den Kompatibilitätsvertrag, der vor dem Rendern gegen bereitgestellte Daten geprüft wird. Speichern "
"erzeugt eine neue unveränderliche Revision. Veröffentlichen markiert eine Revision für endgültige Ausgabe, ohne ältere "
"Revisionen oder Nachweise umzuschreiben. Die Vorschau validiert und rendert begrenzte Beispielausgabe; endgültiges Rendering "
"verlangt die veröffentlichte Revision und zeichnet Vorlagen-, Eingabe- und Ausgabehash sowie Renderer-Nachweise auf. Files "
"darf das Artefakt aufbewahren, wenn die optionale Fähigkeit verfügbar ist. Löschen entfernt die Vorlage aus zukünftiger "
"Auswahl, schreibt aber aufbewahrte Rendering-Nachweise nicht um."
),
}
},
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"templates.field.type",
"templates.field.locale",
"templates.field.visibility",
"templates.field.usages",
"templates.field.required-data",
"templates.action.publish",
"templates.action.delete",
],
"consequence_classes": {
"save_revision": "Creates a new immutable Template revision.",
"publish_revision": "Makes the selected immutable revision eligible for final consumer output.",
"render_final": "Creates retained render evidence and may persist an artifact through Files.",
"delete_template": "Prevents future selection without rewriting retained revisions or render evidence.",
},
},
),
)
def _router(_context: ModuleContext):
from govoplan_templates.backend.router import router
return router
def _catalog(context: ModuleContext):
from govoplan_templates.backend.capabilities import catalog_capability
return catalog_capability(context)
def _renderer(context: ModuleContext):
from govoplan_templates.backend.capabilities import renderer_capability
return renderer_capability(context)
def _content_library(context: ModuleContext):
from govoplan_templates.backend.capabilities import content_library_capability
return content_library_capability(context)
def _dsar_provider(_context: ModuleContext) -> TemplatesDsarProvider:
return TemplatesDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"templates": session.query(template_models.TemplateDefinition)
.filter(
template_models.TemplateDefinition.tenant_id == tenant_id,
template_models.TemplateDefinition.deleted_at.is_(None),
)
.count(),
"template_renders": session.query(template_models.TemplateRender)
.filter(
template_models.TemplateRender.tenant_id == tenant_id,
)
.count(),
}
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=(),
optional_dependencies=("files", "dist_lists", "campaigns", "audit"),
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
provides_interfaces=(
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_CATALOG, version=MODULE_VERSION
),
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION
),
ModuleInterfaceProvider(name=TEMPLATES_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name=CAPABILITY_FILES_ARTIFACT_STORE,
version_min="0.1.14",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(
NavItem(
path="/templates",
label=MODULE_NAME,
icon="layout-template",
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/templates-webui",
routes=(
FrontendRoute(
path="/templates",
component="TemplatesPage",
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
nav_items=(
NavItem(
path="/templates",
label=MODULE_NAME,
icon="layout-template",
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
product_areas=(
ProductAreaContribution(
id="records-documents",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.records_documents",
icon="folder",
description="i18n:govoplan-core.product_area.records_documents_description",
surface_ids=("templates.nav.templates", "templates.route.templates"),
order=30,
),
),
view_surfaces=(
ViewSurface(
id="templates.page",
module_id=MODULE_ID,
kind="route",
label="Templates",
order=75,
),
ViewSurface(
id="templates.library",
module_id=MODULE_ID,
kind="section",
label="Template library",
order=10,
),
ViewSurface(
id="templates.editor",
module_id=MODULE_ID,
kind="section",
label="Template editor",
order=20,
),
ViewSurface(
id="templates.preview",
module_id=MODULE_ID,
kind="section",
label="Template preview and output",
order=30,
),
),
),
route_factory=_router,
capability_factories={
CAPABILITY_TEMPLATE_CATALOG: _catalog,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY: _content_library,
CAPABILITY_TEMPLATE_RENDERER: _renderer,
TEMPLATES_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
TEMPLATES_DSAR_CAPABILITY: CapabilityDocumentation(
label="Templates data-subject request provider",
summary=(
"Exports minimized template-author and render attribution without "
"template, input, or output payloads."
),
contract_version="0.1.0",
),
},
tenant_summary_providers=(_tenant_summary,),
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(
template_models.TemplateRender,
template_models.TemplateRevision,
template_models.TemplateDefinition,
label=MODULE_NAME,
),
retirement_notes=(
"Destructive retirement removes template definitions, immutable revisions, bounded outputs, "
"and render evidence after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
template_models.TemplateDefinition,
template_models.TemplateRevision,
template_models.TemplateRender,
label=MODULE_NAME,
),
),
documentation=(
DocumentationTopic(
id="templates.data-subject-requests",
title="Template data-subject requests",
summary=(
"Export template-author and render activity without content or supplied data."
),
body=(
"Templates correlates only an exact tenant account identifier and can "
"narrow an already verified search to one template, revision, or render. "
"It returns minimized definition, publication, and render lifecycle "
"metadata. Template text and HTML, required fields, layouts, metadata, "
"render input snapshots, filenames, diagnostics, artifact references, "
"output bytes, hashes, and idempotency keys are excluded. Rendered "
"business data belongs to the supplying module and is not inferred from "
"opaque Template payloads. Attribution remains retained with immutable "
"definition and render evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "files", "campaigns", "audit"),
order=90,
translations={
"de": {
"title": "Betroffenenanfragen für Vorlagen",
"summary": "Aktivität von Vorlagenautoren und Renderläufen ohne Inhalt oder bereitgestellte Daten exportieren.",
"body": (
"Templates gleicht nur eine exakte mandantenbezogene Kontokennung ab und kann eine bereits verifizierte Suche auf eine "
"Vorlage, Revision oder einen Renderlauf begrenzen. Ausgegeben werden minimierte Lebenszyklusmetadaten für Definition, "
"Veröffentlichung und Rendering. Vorlagentext und -HTML, Pflichtfelder, Layouts, Metadaten, Eingabe-Snapshots, Dateinamen, "
"Diagnosen, Artefaktverweise, Ausgabebytes, Hashes und Idempotenzschlüssel sind ausgeschlossen. Gerenderte Fachdaten gehören "
"dem bereitstellenden Modul und werden nicht aus undurchsichtigen Vorlagennutzdaten abgeleitet. Die Zuordnung bleibt mit "
"unveränderlichen Definitions- und Rendering-Nachweisen erhalten."
),
}
},
metadata={
"help_contexts": ["templates.page", "privacy.data-subject-requests"],
"consequence_classes": {
"export_template_attribution": "Returns minimized author, publication, and render activity.",
"exclude_template_payloads": "Does not return template content or render inputs and outputs.",
},
},
),
*DOCUMENTATION,
),
architecture=declared_module_architecture(
layer="content_records_evidence",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/TEMPLATE_BOUNDARY.md",
test_ref="tests/test_templates.py",
known_limits=(
"The baseline emits safe deterministic HTML/text for browser or OS printing; PDF and printer delivery remain connector concerns.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"template definition",
"template revision",
"template render evidence",
),
non_owned_concepts=("recipient", "campaign", "file asset", "printer endpoint"),
recovery_docs=("docs/TEMPLATE_BOUNDARY.md",),
security_docs=("docs/TEMPLATE_BOUNDARY.md",),
operations_docs=("README.md",),
),
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Alembic migrations for Templates."""
@@ -0,0 +1 @@
"""Templates migration revisions."""
@@ -0,0 +1,135 @@
"""templates baseline
Revision ID: a3f7c9d2e1b4
Revises: None
Create Date: 2026-08-02 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a3f7c9d2e1b4"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"template_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("name", sa.String(length=300), nullable=False),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("template_type", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("current_revision_id", sa.String(length=36), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_template_definitions")),
)
for column in ("tenant_id", "scope_type", "scope_id", "template_type", "status", "published_revision_id", "created_by_account_id", "updated_by_account_id", "deleted_at"):
op.create_index(op.f(f"ix_template_definitions_{column}"), "template_definitions", [column])
op.create_index("ix_template_definitions_tenant_status", "template_definitions", ["tenant_id", "status", "updated_at"])
op.create_index(
"uq_template_definitions_active_tenant_slug",
"template_definitions",
["tenant_id", "slug"],
unique=True,
sqlite_where=sa.text("deleted_at IS NULL AND scope_id IS NULL"),
postgresql_where=sa.text("deleted_at IS NULL AND scope_id IS NULL"),
)
op.create_index(
"uq_template_definitions_active_named_scope_slug",
"template_definitions",
["tenant_id", "scope_type", "scope_id", "slug"],
unique=True,
sqlite_where=sa.text("deleted_at IS NULL AND scope_id IS NOT NULL"),
postgresql_where=sa.text("deleted_at IS NULL AND scope_id IS NOT NULL"),
)
op.create_table(
"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("revision", sa.Integer(), nullable=False),
sa.Column("definition_hash", sa.String(length=64), nullable=False),
sa.Column("template_type", sa.String(length=40), nullable=False),
sa.Column("usages", sa.JSON(), nullable=False),
sa.Column("locale", sa.String(length=35), nullable=False),
sa.Column("required_fields", sa.JSON(), nullable=False),
sa.Column("output_profiles", sa.JSON(), nullable=False),
sa.Column("content_text", sa.Text(), nullable=True),
sa.Column("content_html", sa.Text(), nullable=True),
sa.Column("layout", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("published_by_account_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["template_definitions.id"], name=op.f("fk_template_revisions_template_id_template_definitions"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_template_revisions")),
sa.UniqueConstraint("template_id", "revision", name="uq_template_revision_number"),
)
for column in ("tenant_id", "template_id", "definition_hash", "template_type", "locale", "created_by_account_id", "published_at", "published_by_account_id"):
op.create_index(op.f(f"ix_template_revisions_{column}"), "template_revisions", [column])
op.create_index("ix_template_revisions_tenant_template", "template_revisions", ["tenant_id", "template_id"])
op.create_index("ix_template_revisions_hash", "template_revisions", ["tenant_id", "definition_hash"])
op.create_table(
"template_renders",
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("revision_id", sa.String(length=36), nullable=False),
sa.Column("revision_number", sa.Integer(), nullable=False),
sa.Column("mode", sa.String(length=20), nullable=False),
sa.Column("usage", sa.String(length=80), nullable=True),
sa.Column("output_format", sa.String(length=20), nullable=False),
sa.Column("content_type", sa.String(length=100), nullable=False),
sa.Column("filename", sa.String(length=500), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("template_hash", sa.String(length=64), nullable=False),
sa.Column("input_hash", sa.String(length=64), nullable=False),
sa.Column("renderer_version", sa.String(length=40), nullable=False),
sa.Column("output_sha256", sa.String(length=64), nullable=False),
sa.Column("output_size_bytes", sa.Integer(), nullable=False),
sa.Column("item_count", sa.Integer(), nullable=False),
sa.Column("page_count", sa.Integer(), nullable=False),
sa.Column("diagnostics", sa.JSON(), nullable=False),
sa.Column("input_snapshot", sa.JSON(), nullable=False),
sa.Column("artifact_ref", sa.JSON(), nullable=True),
sa.Column("payload", sa.LargeBinary(), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["template_definitions.id"], name=op.f("fk_template_renders_template_id_template_definitions"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["revision_id"], ["template_revisions.id"], name=op.f("fk_template_renders_revision_id_template_revisions"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_template_renders")),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_template_render_idempotency"),
)
for column in ("tenant_id", "template_id", "revision_id", "mode", "usage", "idempotency_key", "created_by_account_id"):
op.create_index(op.f(f"ix_template_renders_{column}"), "template_renders", [column])
op.create_index("ix_template_renders_tenant_template", "template_renders", ["tenant_id", "template_id", "created_at"])
op.create_index("ix_template_renders_input_hash", "template_renders", ["tenant_id", "input_hash"])
def downgrade() -> None:
op.drop_table("template_renders")
op.drop_table("template_revisions")
op.drop_table("template_definitions")
+783
View File
@@ -0,0 +1,783 @@
from __future__ import annotations
import dataclasses
import hashlib
import json
import math
import re
from collections.abc import Mapping, Sequence
from html import escape
from html.parser import HTMLParser
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.files import (
CAPABILITY_FILES_ARTIFACT_STORE,
ManagedArtifactStore,
ManagedArtifactWriteRequest,
)
from govoplan_core.core.templates import (
TemplateArtifactRef,
TemplateCompatibilityError,
TemplateRenderError,
TemplateRenderRequest,
TemplateRenderResult,
)
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
from govoplan_templates.backend.service import (
ADMIN_SCOPE,
RENDER_SCOPE,
compatibility,
get_template,
get_template_revision,
)
RENDERER_VERSION = "templates-html-1"
MAX_OUTPUT_BYTES = 5 * 1024 * 1024
MAX_ITEMS = 5_000
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
class SqlTemplateRenderer:
def __init__(self, registry: object | None = None) -> None:
self.registry = registry
def render(
self,
session: object,
principal: object,
*,
request: TemplateRenderRequest,
) -> TemplateRenderResult:
if not isinstance(session, Session):
raise TypeError("Template rendering requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Template rendering requires an API principal.")
if not principal.has(RENDER_SCOPE) and not principal.has("templates:template:admin"):
raise PermissionError(f"Template rendering requires {RENDER_SCOPE}.")
return render_template(
session,
principal,
registry=self.registry,
request=request,
)
def render_template(
session: Session,
principal: ApiPrincipal,
*,
registry: object | None,
request: TemplateRenderRequest,
) -> TemplateRenderResult:
definition = get_template(session, principal, request.template_id)
revision = get_template_revision(
session,
definition,
revision=request.revision,
published_preferred=request.mode == "final" and request.revision is None,
)
if request.mode == "final" and revision.published_at is None:
raise TemplateCompatibilityError(
"Final output requires a published template revision."
)
if len(request.items) > MAX_ITEMS:
raise TemplateRenderError(f"Template renders are limited to {MAX_ITEMS} items.")
items = tuple(request.items) or ({},)
diagnostics = _validate_render_inputs(revision, request, items)
blocking = [item for item in diagnostics if item.get("severity") == "error"]
if blocking:
raise TemplateCompatibilityError(
"; ".join(str(item.get("message") or "Template input is incompatible.") for item in blocking)
)
input_hash = _canonical_hash(
{
"usage": request.usage,
"locale": request.locale,
"output_format": request.output_format,
"profile_id": request.profile_id,
"parameters": request.parameters,
"items": items,
"input_snapshot": request.input_snapshot,
}
)
existing = _idempotent_render(
session,
principal,
request=request,
revision=revision,
input_hash=input_hash,
)
if existing is not None:
return render_result(existing)
payload, content_type, page_count = _render_payload(
definition,
revision,
request=request,
items=items,
)
if len(payload) > MAX_OUTPUT_BYTES:
raise TemplateRenderError(
f"Rendered output exceeds the {MAX_OUTPUT_BYTES} byte bounded-download limit."
)
output_sha256 = hashlib.sha256(payload).hexdigest()
filename = _output_filename(definition, revision, request.output_format)
artifact = _persist_artifact(
registry,
session,
principal,
request=request,
definition=definition,
revision=revision,
payload=payload,
filename=filename,
content_type=content_type,
input_hash=input_hash,
output_sha256=output_sha256,
diagnostics=diagnostics,
)
row = TemplateRender(
tenant_id=principal.tenant_id,
template_id=definition.id,
revision_id=revision.id,
revision_number=revision.revision,
mode=request.mode,
usage=request.usage,
output_format=request.output_format,
content_type=content_type,
filename=filename,
idempotency_key=request.idempotency_key,
template_hash=revision.definition_hash,
input_hash=input_hash,
renderer_version=RENDERER_VERSION,
output_sha256=output_sha256,
output_size_bytes=len(payload),
item_count=len(items),
page_count=page_count,
diagnostics=diagnostics,
input_snapshot=dict(request.input_snapshot),
artifact_ref=dataclasses.asdict(artifact) if artifact else None,
payload=None if artifact is not None else payload,
created_by_account_id=principal.account_id,
)
session.add(row)
session.flush()
if artifact is None:
artifact = TemplateArtifactRef(
kind="bounded_download",
filename=filename,
content_type=content_type,
size_bytes=len(payload),
sha256=output_sha256,
download_path=f"/api/v1/templates/renders/{row.id}/download",
provenance={"module": "templates", "bounded": True},
)
row.artifact_ref = dataclasses.asdict(artifact)
session.add(row)
session.flush()
return render_result(row)
def get_render_for_principal(
session: Session,
principal: ApiPrincipal,
render_id: str,
) -> TemplateRender:
row = session.scalar(
select(TemplateRender).where(
TemplateRender.id == render_id,
TemplateRender.tenant_id == principal.tenant_id,
)
)
if row is None:
raise TemplateRenderError("Template render not found.")
get_template(session, principal, row.template_id)
if (
row.created_by_account_id != principal.account_id
and not principal.has(ADMIN_SCOPE)
):
# Render payloads may contain recipient-specific or otherwise
# confidential data. Do not reveal whether another actor's render
# exists to ordinary template readers.
raise TemplateRenderError("Template render not found.")
return row
def list_renders(
session: Session,
principal: ApiPrincipal,
*,
template_id: str | None = None,
limit: int = 100,
) -> list[TemplateRender]:
statement = select(TemplateRender).where(
TemplateRender.tenant_id == principal.tenant_id
)
if not principal.has(ADMIN_SCOPE):
statement = statement.where(
TemplateRender.created_by_account_id == principal.account_id
)
if template_id:
get_template(session, principal, template_id)
statement = statement.where(TemplateRender.template_id == template_id)
rows = list(
session.scalars(
statement.order_by(TemplateRender.created_at.desc()).limit(
max(1, min(limit, 500))
)
)
)
visible_template_ids = {
row.template_id
for row in rows
if _template_visible(session, principal, row.template_id)
}
return [row for row in rows if row.template_id in visible_template_ids]
def render_result(row: TemplateRender) -> TemplateRenderResult:
artifact = (
TemplateArtifactRef(**row.artifact_ref)
if isinstance(row.artifact_ref, dict)
else None
)
return TemplateRenderResult(
render_id=row.id,
template_id=row.template_id,
revision_id=row.revision_id,
revision=row.revision_number,
template_hash=row.template_hash,
input_hash=row.input_hash,
renderer_version=row.renderer_version,
output_format=row.output_format, # type: ignore[arg-type]
content_type=row.content_type,
filename=row.filename,
item_count=row.item_count,
page_count=row.page_count,
output_sha256=row.output_sha256,
output_size_bytes=row.output_size_bytes,
diagnostics=tuple(row.diagnostics or []),
artifact=artifact,
generated_at=row.created_at,
payload=row.payload,
)
def _validate_render_inputs(
revision: TemplateRevision,
request: TemplateRenderRequest,
items: Sequence[Mapping[str, object]],
) -> list[dict[str, object]]:
diagnostics: list[dict[str, object]] = []
available_fields = _available_field_types(request.parameters, items)
contract = compatibility(
revision,
usage=request.usage,
output_format=request.output_format,
available_fields=available_fields,
)
diagnostics.extend(dict(item) for item in contract.diagnostics)
if request.profile_id:
profile = next(
(
item
for item in revision.output_profiles
if isinstance(item, dict) and item.get("id") == request.profile_id
),
None,
)
if profile is None:
diagnostics.append(
{
"code": "template.output_profile_missing",
"severity": "error",
"message": f"Output profile {request.profile_id} is not defined by this revision.",
}
)
elif profile.get("output_format") != request.output_format:
diagnostics.append(
{
"code": "template.output_profile_format_mismatch",
"severity": "error",
"message": (
f"Output profile {request.profile_id} does not provide "
f"{request.output_format} output."
),
}
)
if request.locale and revision.locale.lower() != request.locale.lower():
diagnostics.append(
{
"code": "template.locale_mismatch",
"severity": "warning",
"message": f"Requested locale {request.locale} uses template locale {revision.locale}.",
}
)
for index, item in enumerate(items):
context = _render_context(request.parameters, item, index)
for requirement in revision.required_fields:
if not bool(requirement.get("required", True)):
continue
path = str(requirement.get("path") or "")
value, present = _resolve_path(context, path)
if not present or value in (None, ""):
diagnostics.append(
{
"code": "template.item_required_field_missing",
"severity": "error",
"message": f"Item {index + 1} is missing required field {path}.",
"item_index": index,
"field": path,
}
)
continue
expected = str(requirement.get("value_type") or "string")
if not _value_matches_type(value, expected):
diagnostics.append(
{
"code": "template.item_field_type_invalid",
"severity": "error",
"message": f"Item {index + 1} field {path} is not {expected}.",
"item_index": index,
"field": path,
}
)
return _unique_diagnostics(diagnostics)
def _render_payload(
definition: TemplateDefinition,
revision: TemplateRevision,
*,
request: TemplateRenderRequest,
items: Sequence[Mapping[str, object]],
) -> tuple[bytes, str, int]:
if request.output_format == "text":
body = revision.content_text or _html_to_text(revision.content_html or "")
separator = "\n\n---\n\n" if revision.template_type != "list_layout" else "\n"
rendered = _render_items(body, request.parameters, items, html=False, separator=separator)
payload = separator.join(rendered).encode("utf-8")
return payload, "text/plain; charset=utf-8", _page_count(revision, len(items))
body = revision.content_html or f"<pre>{escape(revision.content_text or '')}</pre>"
rendered = _render_items(body, request.parameters, items, html=True)
page_count = _page_count(revision, len(items))
document = _html_document(definition, revision, rendered)
payload = document.encode("utf-8")
if len(payload) > MAX_OUTPUT_BYTES:
_output_limit_exceeded()
return payload, "text/html; charset=utf-8", page_count
def _output_limit_exceeded() -> None:
raise TemplateRenderError(
f"Rendered output exceeds the {MAX_OUTPUT_BYTES} byte bounded-download limit."
)
def _render_items(
body: str,
parameters: Mapping[str, object],
items: Sequence[Mapping[str, object]],
*,
html: bool,
separator: str = "",
) -> list[str]:
# Reject as soon as the same existing output budget is exhausted; never
# build thousands of oversized documents and only then measure the join.
remaining = MAX_OUTPUT_BYTES
separator_bytes = len(separator.encode("utf-8"))
rendered: list[str] = []
for index, item in enumerate(items):
if index:
remaining -= separator_bytes
if remaining < 0:
_output_limit_exceeded()
value = _substitute(body, _render_context(parameters, item, index), html=html, max_bytes=remaining)
remaining -= len(value.encode("utf-8"))
rendered.append(value)
return rendered
def _html_document(
definition: TemplateDefinition,
revision: TemplateRevision,
rendered: Sequence[str],
) -> str:
page_size = _page_size(revision.layout.get("page_size") or _profile_page_size(revision))
margin = _millimetres(revision.layout.get("margin_mm"), 15.0, minimum=0, maximum=60)
template_type = revision.template_type
if template_type == "label_sheet":
columns = _integer(revision.layout.get("columns"), 3, minimum=1, maximum=12)
rows = _integer(revision.layout.get("rows"), 8, minimum=1, maximum=30)
gap = _millimetres(revision.layout.get("gap_mm"), 2.0, minimum=0, maximum=20)
per_page = columns * rows
pages = []
for start in range(0, len(rendered), per_page):
labels = "".join(f'<section class="template-label">{item}</section>' for item in rendered[start:start + per_page])
pages.append(f'<main class="template-page template-label-sheet">{labels}</main>')
body = "".join(pages)
type_css = (
f".template-label-sheet{{display:grid;grid-template-columns:repeat({columns},minmax(0,1fr));"
f"grid-template-rows:repeat({rows},minmax(0,1fr));gap:{gap}mm;}}"
".template-label{overflow:hidden;border:0.2mm solid #c9c9c9;padding:2mm;}"
)
elif template_type == "list_layout":
body = f'<main class="template-page template-list">{"".join(rendered)}</main>'
type_css = ".template-list>*{break-inside:avoid;}"
else:
body = "".join(f'<main class="template-page">{item}</main>' for item in rendered)
type_css = ""
return (
"<!doctype html><html><head><meta charset=\"utf-8\">"
f"<title>{escape(definition.name)}</title><style>"
f"@page{{size:{page_size};margin:{margin}mm;}}"
"*{box-sizing:border-box;}html,body{margin:0;padding:0;color:#171717;background:#fff;"
"font-family:Arial,Helvetica,sans-serif;font-size:10pt;line-height:1.35;}"
".template-page{break-after:page;min-height:1px;}"
".template-page:last-child{break-after:auto;}table{border-collapse:collapse;width:100%;}"
"th,td{padding:1.5mm;text-align:left;vertical-align:top;}"
f"{type_css}</style></head><body>{body}</body></html>"
)
def _persist_artifact(
registry: object | None,
session: Session,
principal: ApiPrincipal,
*,
request: TemplateRenderRequest,
definition: TemplateDefinition,
revision: TemplateRevision,
payload: bytes,
filename: str,
content_type: str,
input_hash: str,
output_sha256: str,
diagnostics: list[dict[str, object]],
) -> TemplateArtifactRef | None:
if not request.persist_to_files:
return None
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_ARTIFACT_STORE)
):
diagnostics.append(
{
"code": "template.files_unavailable",
"severity": "warning",
"message": "Files artifact storage is unavailable; using a bounded Templates download.",
}
)
return None
capability = registry.capability(CAPABILITY_FILES_ARTIFACT_STORE)
if not isinstance(capability, ManagedArtifactStore):
diagnostics.append(
{
"code": "template.files_contract_invalid",
"severity": "warning",
"message": "Files artifact storage has an incompatible contract; using a bounded Templates download.",
}
)
return None
try:
stored = capability.store_artifact(
session,
principal,
request=ManagedArtifactWriteRequest(
filename=filename,
payload=payload,
content_type=content_type,
folder="Generated/Templates",
description=f"Rendered from template {definition.name} revision {revision.revision}.",
idempotency_key=request.idempotency_key,
metadata={
"producer_module": "templates",
"template_id": definition.id,
"template_revision_id": revision.id,
"template_hash": revision.definition_hash,
"input_hash": input_hash,
"output_sha256": output_sha256,
},
),
)
except (PermissionError, RuntimeError, ValueError) as exc:
diagnostics.append(
{
"code": "template.files_store_failed",
"severity": "warning",
"message": "Managed Files persistence was not permitted or available; using a bounded Templates download.",
"error_type": type(exc).__name__,
}
)
return None
return TemplateArtifactRef(
kind="managed_file",
filename=stored.filename,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
sha256=stored.sha256,
file_asset_id=stored.file_asset_id,
file_version_id=stored.file_version_id,
download_path=f"/api/v1/files/{stored.file_asset_id}/download",
provenance=dict(stored.provenance),
)
def _idempotent_render(
session: Session,
principal: ApiPrincipal,
*,
request: TemplateRenderRequest,
revision: TemplateRevision,
input_hash: str,
) -> TemplateRender | None:
if not request.idempotency_key:
return None
existing = session.scalar(
select(TemplateRender).where(
TemplateRender.tenant_id == principal.tenant_id,
TemplateRender.idempotency_key == request.idempotency_key,
)
)
if existing is None:
return None
if (
existing.template_id != revision.template_id
or existing.revision_id != revision.id
or existing.input_hash != input_hash
or existing.output_format != request.output_format
or existing.mode != request.mode
):
raise TemplateRenderError(
"The render idempotency key was already used for different input."
)
return existing
def _render_context(
parameters: Mapping[str, object],
item: Mapping[str, object],
index: int,
) -> dict[str, object]:
return {
**dict(parameters),
**dict(item),
"parameters": dict(parameters),
"item": dict(item),
"recipient": dict(item),
"index": index + 1,
}
def _substitute(template: str, context: Mapping[str, object], *, html: bool, max_bytes: int | None = None) -> str:
remaining = MAX_OUTPUT_BYTES if max_bytes is None else max_bytes
pieces: list[str] = []
def append(value: str) -> None:
nonlocal remaining
# Character count is a cheap lower bound before allocating UTF-8 bytes.
if len(value) > remaining:
_output_limit_exceeded()
remaining -= len(value.encode("utf-8"))
if remaining < 0:
_output_limit_exceeded()
pieces.append(value)
previous = 0
for match in _TOKEN_PATTERN.finditer(template):
append(template[previous:match.start()])
value, present = _resolve_path(context, match.group(1))
if present and value is not None:
rendered = _display_value(value)
if len(rendered) > remaining:
_output_limit_exceeded()
append(escape(rendered, quote=True) if html else rendered)
previous = match.end()
append(template[previous:])
return "".join(pieces)
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
if path in context:
return context[path], True
current: object = context
for part in path.split("."):
if not isinstance(current, Mapping) or part not in current:
return None, False
current = current[part]
return current, True
def _available_field_types(
parameters: Mapping[str, object],
items: Sequence[Mapping[str, object]],
) -> dict[str, str]:
result: dict[str, str] = {}
for prefix, value in (("parameters", parameters),):
_flatten_types(value, prefix, result)
for item in items:
_flatten_types(item, "", result)
_flatten_types(item, "item", result)
_flatten_types(item, "recipient", result)
return result
def _flatten_types(value: object, prefix: str, result: dict[str, str]) -> None:
if isinstance(value, Mapping):
if prefix:
result.setdefault(prefix, "object")
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_flatten_types(item, path, result)
return
result.setdefault(prefix, _value_type(value))
def _value_type(value: object) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, float):
return "number"
if isinstance(value, Mapping):
return "object"
if isinstance(value, (list, tuple)):
return "array"
return "string"
def _value_matches_type(value: object, expected: str) -> bool:
actual = _value_type(value)
if expected == "number":
return actual in {"integer", "number"}
if expected in {"date", "datetime"}:
return isinstance(value, str) and bool(value.strip())
return actual == expected
def _display_value(value: object) -> str:
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _canonical_hash(value: object) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _page_count(revision: TemplateRevision, item_count: int) -> int:
if revision.template_type == "label_sheet":
columns = _integer(revision.layout.get("columns"), 3, minimum=1, maximum=12)
rows = _integer(revision.layout.get("rows"), 8, minimum=1, maximum=30)
return max(1, math.ceil(item_count / (columns * rows)))
if revision.template_type == "list_layout":
return 1
return max(1, item_count)
def _output_filename(
definition: TemplateDefinition,
revision: TemplateRevision,
output_format: str,
) -> str:
extension = "html" if output_format == "html" else "txt"
return f"{definition.slug}-r{revision.revision}.{extension}"
def _profile_page_size(revision: TemplateRevision) -> object:
for profile in revision.output_profiles:
page = profile.get("page") if isinstance(profile, dict) else None
if isinstance(page, dict) and page.get("size"):
return page["size"]
return "A4"
def _page_size(value: object) -> str:
normalized = str(value or "A4").upper()
return normalized if normalized in {"A3", "A4", "A5", "LETTER", "LEGAL", "DL"} else "A4"
def _integer(value: object, fallback: int, *, minimum: int, maximum: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
number = fallback
return max(minimum, min(maximum, number))
def _millimetres(value: object, fallback: float, *, minimum: float, maximum: float) -> str:
try:
number = float(value)
except (TypeError, ValueError):
number = fallback
number = max(minimum, min(maximum, number))
return f"{number:.2f}".rstrip("0").rstrip(".")
class _PlainTextExtractor(HTMLParser):
block_tags = {"br", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "tr"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
del attrs
if tag in self.block_tags:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
self.parts.append(data)
def _html_to_text(value: str) -> str:
parser = _PlainTextExtractor()
parser.feed(value)
parser.close()
return "\n".join(line.strip() for line in "".join(parser.parts).splitlines() if line.strip())
def _unique_diagnostics(items: Sequence[dict[str, object]]) -> list[dict[str, object]]:
seen: set[str] = set()
result: list[dict[str, object]] = []
for item in items:
key = json.dumps(item, sort_keys=True, default=str)
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def _template_visible(session: Session, principal: ApiPrincipal, template_id: str) -> bool:
try:
get_template(session, principal, template_id)
return True
except Exception:
return False
__all__ = [
"MAX_ITEMS",
"MAX_OUTPUT_BYTES",
"RENDERER_VERSION",
"SqlTemplateRenderer",
"get_render_for_principal",
"list_renders",
"render_result",
"render_template",
]
+529
View File
@@ -0,0 +1,529 @@
from __future__ import annotations
from dataclasses import asdict
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.core.templates import (
TemplateCompatibilityError,
TemplateNotFoundError,
TemplateRenderError,
TemplateRenderRequest,
)
from govoplan_core.db.session import get_session
from govoplan_templates.backend.db.models import TemplateDefinition, TemplateRevision
from govoplan_templates.backend.rendering import (
get_render_for_principal,
list_renders,
render_result,
render_template,
)
from govoplan_templates.backend.schemas import (
TemplateCompatibilityRequest,
TemplateCompatibilityResponse,
TemplateCreateRequest,
TemplateDeleteRequest,
TemplateListResponse,
TemplatePublishRequest,
TemplateRenderListResponse,
TemplateRenderRequestModel,
TemplateRenderResponse,
TemplateResponse,
TemplateRevisionResponse,
TemplateUpdateRequest,
)
from govoplan_templates.backend.service import (
ADMIN_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE,
RENDER_SCOPE,
WRITE_SCOPE,
compatibility,
create_template,
delete_template,
get_template,
get_template_revision,
list_template_revisions,
list_templates,
publish_template,
update_template,
)
router = APIRouter(prefix="/templates", tags=["templates"])
@router.get("", response_model=TemplateListResponse)
def api_list_templates(
query: str = Query(default="", max_length=200),
usage: str | None = Query(default=None, max_length=80),
template_type: str | None = Query(default=None, max_length=40),
locale: str | None = Query(default=None, max_length=35),
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateListResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
rows = list_templates(
session,
principal,
query=query,
usage=usage,
template_type=template_type,
locale=locale,
limit=limit,
)
return TemplateListResponse(
items=[_template_response(session, principal, item) for item in rows],
total=len(rows),
)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def api_create_template(
payload: TemplateCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item, revision = create_template(session, principal, payload)
_record_change(
session,
principal,
item,
revision,
action="templates.template.created",
event_type="templates.template.created.v1",
)
session.commit()
except (TemplateCompatibilityError, IntegrityError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.get("/{template_id}", response_model=TemplateResponse)
def api_get_template(
template_id: str,
response: Response,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
item_revision = get_template_revision(session, item, revision=revision)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, item_revision)
@router.put("/{template_id}", response_model=TemplateResponse)
def api_update_template(
template_id: str,
payload: TemplateUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
item, revision = update_template(session, principal, item, payload)
_record_change(
session,
principal,
item,
revision,
action="templates.template.revised",
event_type="templates.template.revised.v1",
)
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError, IntegrityError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_template(
template_id: str,
payload: TemplateDeleteRequest,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
delete_template(session, principal, item, base_revision=payload.base_revision)
_audit(session, principal, action="templates.template.deleted", item=item)
_event(session, principal, item.id, "templates.template.deleted.v1")
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError) as exc:
session.rollback()
raise _error(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{template_id}/revisions", response_model=list[TemplateRevisionResponse])
def api_list_revisions(
template_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[TemplateRevisionResponse]:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
return [_revision_response(row) for row in list_template_revisions(session, item)]
@router.post("/{template_id}/publish", response_model=TemplateResponse)
def api_publish_template(
template_id: str,
payload: TemplatePublishRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
item, revision = publish_template(
session,
principal,
item,
revision=payload.revision,
base_revision=payload.base_revision,
)
_record_change(
session,
principal,
item,
revision,
action="templates.template.published",
event_type="templates.template.published.v1",
)
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.post("/{template_id}/compatibility", response_model=TemplateCompatibilityResponse)
def api_check_compatibility(
template_id: str,
payload: TemplateCompatibilityRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateCompatibilityResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
revision = get_template_revision(
session,
item,
revision=payload.revision,
published_preferred=payload.revision is None,
)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
return TemplateCompatibilityResponse.model_validate(
asdict(
compatibility(
revision,
usage=payload.usage,
output_format=payload.output_format,
available_fields=payload.available_fields,
)
)
)
@router.post("/{template_id}/render", response_model=TemplateRenderResponse)
def api_render_template(
template_id: str,
payload: TemplateRenderRequestModel,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateRenderResponse:
_require(principal, RENDER_SCOPE, ADMIN_SCOPE)
try:
result = render_template(
session,
principal,
registry=get_registry(),
request=TemplateRenderRequest(template_id=template_id, **payload.model_dump()),
)
_audit(
session,
principal,
action=f"templates.render.{payload.mode}",
item_id=result.render_id,
details={
"template_id": result.template_id,
"revision_id": result.revision_id,
"template_hash": result.template_hash,
"input_hash": result.input_hash,
"output_sha256": result.output_sha256,
"item_count": result.item_count,
"page_count": result.page_count,
},
)
_event(
session,
principal,
result.render_id,
f"templates.render.{payload.mode}.v1",
resource_type="template_render",
)
session.commit()
except (TemplateCompatibilityError, TemplateNotFoundError, TemplateRenderError) as exc:
session.rollback()
raise _error(exc) from exc
return _render_response(result)
@router.get("/renders/history", response_model=TemplateRenderListResponse)
def api_list_renders(
template_id: str | None = Query(default=None, max_length=36),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateRenderListResponse:
_require(principal, READ_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
rows = list_renders(session, principal, template_id=template_id, limit=limit)
return TemplateRenderListResponse(
items=[_render_response(render_result(row)) for row in rows],
total=len(rows),
)
@router.get("/renders/{render_id}/download")
def api_download_render(
render_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, READ_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
try:
row = get_render_for_principal(session, principal, render_id)
except (TemplateNotFoundError, TemplateRenderError) as exc:
raise _error(exc) from exc
if row.payload is None:
raise HTTPException(
status_code=409,
detail="This output is managed by Files and is not retained as a Templates download.",
)
filename = quote(row.filename, safe="._-")
return Response(
content=row.payload,
media_type=row.content_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}",
"X-Content-SHA256": row.output_sha256,
"X-Content-Type-Options": "nosniff",
},
)
def _require(principal: ApiPrincipal, *scopes: str) -> None:
if not any(principal.has(scope) for scope in scopes):
raise HTTPException(status_code=403, detail=f"Requires one of: {', '.join(scopes)}")
def _template_response(
session: Session,
principal: ApiPrincipal,
item: TemplateDefinition,
revision: TemplateRevision | None = None,
) -> TemplateResponse:
revision = revision or get_template_revision(session, item)
read_only = not (
principal.has(ADMIN_SCOPE)
or item.scope_type == "tenant"
or (item.scope_type == "user" and item.scope_id == principal.account_id)
or (item.scope_type == "group" and item.scope_id in principal.group_ids)
)
return TemplateResponse(
id=item.id,
tenant_id=item.tenant_id,
scope_type=item.scope_type,
scope_id=item.scope_id,
name=item.name,
slug=item.slug,
description=item.description,
template_type=item.template_type,
status=item.status,
current_revision=item.current_revision,
resource_revision=item.resource_revision,
strong_etag=item.strong_etag,
current_revision_id=item.current_revision_id,
published_revision_id=item.published_revision_id,
read_only=read_only,
metadata=dict(item.metadata_ or {}),
created_at=item.created_at,
updated_at=item.updated_at,
revision=_revision_response(revision),
)
def _revision_response(revision: TemplateRevision) -> TemplateRevisionResponse:
return TemplateRevisionResponse(
id=revision.id,
revision=revision.revision,
definition_hash=revision.definition_hash,
template_type=revision.template_type,
usages=list(revision.usages or []),
locale=revision.locale,
required_fields=list(revision.required_fields or []),
output_profiles=list(revision.output_profiles or []),
content_text=revision.content_text,
content_html=revision.content_html,
layout=dict(revision.layout or {}),
metadata=dict(revision.metadata_ or {}),
created_by_account_id=revision.created_by_account_id,
published_at=revision.published_at,
published_by_account_id=revision.published_by_account_id,
created_at=revision.created_at,
)
def _render_response(result) -> TemplateRenderResponse:
payload = asdict(result)
payload.pop("payload", None)
return TemplateRenderResponse.model_validate(payload)
def _record_change(
session: Session,
principal: ApiPrincipal,
item: TemplateDefinition,
revision: TemplateRevision,
*,
action: str,
event_type: str,
) -> None:
_audit(
session,
principal,
action=action,
item=item,
details={
"revision": revision.revision,
"definition_hash": revision.definition_hash,
"template_type": revision.template_type,
"usages": list(revision.usages or []),
},
)
_event(session, principal, item.id, event_type)
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
item: TemplateDefinition | None = None,
item_id: str | None = None,
details: dict[str, object] | None = None,
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="template" if item is not None else "template_render",
object_id=item.id if item is not None else str(item_id or ""),
details=details or {},
commit=False,
)
def _event(
session: Session,
principal: ApiPrincipal,
resource_id: str,
event_type: str,
*,
resource_type: str = "template",
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id="templates",
actor=EventActorRef(type="account", id=principal.account_id),
tenant=EventTenantRef(id=principal.tenant_id),
resource=EventObjectRef(type=resource_type, id=resource_id),
classification="internal",
),
)
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(status_code=428, detail=exc.as_dict())
if isinstance(exc, RevisionConflictError):
return HTTPException(status_code=412, detail=exc.as_dict())
if isinstance(exc, ConcurrencyError):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, TemplateNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, IntegrityError):
return HTTPException(status_code=409, detail="Template data conflicts with an existing record.")
return HTTPException(status_code=422, detail=str(exc))
__all__ = ["router"]
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
TemplateType = Literal[
"label",
"label_sheet",
"envelope",
"serial_letter",
"form_letter",
"list_layout",
"email",
"content_fragment",
"generic",
]
OutputFormat = Literal["html", "text"]
class TemplateFieldRequirementModel(BaseModel):
path: str = Field(min_length=1, max_length=255, pattern=r"^[A-Za-z_][A-Za-z0-9_.-]*$")
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"object",
"array",
] = "string"
label: str | None = Field(default=None, max_length=200)
required: bool = True
description: str | None = Field(default=None, max_length=2000)
class TemplateOutputProfileModel(BaseModel):
id: str = Field(min_length=1, max_length=80, pattern=r"^[A-Za-z0-9_.-]+$")
label: str = Field(min_length=1, max_length=200)
output_format: OutputFormat
media_type: str = Field(min_length=1, max_length=100)
channel: str = Field(default="print", min_length=1, max_length=80)
capabilities: list[str] = Field(default_factory=list, max_length=50)
page: dict[str, Any] = Field(default_factory=dict)
class TemplateRevisionPayload(BaseModel):
template_type: TemplateType
usages: list[str] = Field(min_length=1, max_length=50)
locale: str = Field(default="en", min_length=2, max_length=35)
required_fields: list[TemplateFieldRequirementModel] = Field(default_factory=list, max_length=500)
output_profiles: list[TemplateOutputProfileModel] = Field(default_factory=list, max_length=50)
content_text: str | None = Field(default=None, max_length=1_000_000)
content_html: str | None = Field(default=None, max_length=2_000_000)
layout: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("usages")
@classmethod
def normalize_usages(cls, value: list[str]) -> list[str]:
normalized = [str(item).strip().lower() for item in value if str(item).strip()]
if not normalized:
raise ValueError("At least one template usage is required.")
return list(dict.fromkeys(normalized))
@field_validator("required_fields")
@classmethod
def unique_required_fields(
cls, value: list[TemplateFieldRequirementModel]
) -> list[TemplateFieldRequirementModel]:
paths = [item.path for item in value]
if len(paths) != len(set(paths)):
raise ValueError("Required field paths must be unique.")
return value
@field_validator("output_profiles")
@classmethod
def unique_output_profiles(
cls, value: list[TemplateOutputProfileModel]
) -> list[TemplateOutputProfileModel]:
ids = [item.id for item in value]
if len(ids) != len(set(ids)):
raise ValueError("Output profile IDs must be unique.")
return value
@model_validator(mode="after")
def validate_content(self) -> "TemplateRevisionPayload":
if not (self.content_text and self.content_text.strip()) and not (
self.content_html and self.content_html.strip()
):
raise ValueError("A text or HTML template body is required.")
return self
class TemplateCreateRequest(TemplateRevisionPayload):
name: str = Field(min_length=1, max_length=300)
slug: str | None = Field(default=None, max_length=160, pattern=r"^[A-Za-z0-9_.-]+$")
description: str | None = Field(default=None, max_length=4000)
scope_type: Literal["tenant", "group", "user"] = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after")
def validate_scope(self) -> "TemplateCreateRequest":
if self.scope_type == "tenant" and self.scope_id is not None:
raise ValueError("Tenant templates do not use a scope ID.")
if self.scope_type != "tenant" and not self.scope_id:
raise ValueError("Group and user templates require a scope ID.")
return self
class TemplateUpdateRequest(TemplateCreateRequest):
base_revision: int = Field(ge=1)
class TemplatePublishRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: int | None = Field(default=None, ge=1)
base_revision: int = Field(ge=1)
class TemplateDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=1)
class TemplateCompatibilityRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: int | None = Field(default=None, ge=1)
usage: str | None = Field(default=None, max_length=80)
output_format: OutputFormat | None = None
available_fields: dict[str, str] | list[str] = Field(default_factory=dict)
class TemplateRenderRequestModel(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: int | None = Field(default=None, ge=1)
usage: str | None = Field(default=None, max_length=80)
locale: str | None = Field(default=None, max_length=35)
output_format: OutputFormat = "html"
profile_id: str | None = Field(default=None, max_length=80)
parameters: dict[str, Any] = Field(default_factory=dict)
items: list[dict[str, Any]] = Field(default_factory=list, max_length=5000)
input_snapshot: dict[str, Any] = Field(default_factory=dict)
mode: Literal["preview", "final"] = "preview"
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
persist_to_files: bool = False
@model_validator(mode="after")
def validate_final(self) -> "TemplateRenderRequestModel":
if self.mode == "final" and not self.idempotency_key:
raise ValueError("Final renders require an idempotency key.")
return self
class TemplateRevisionResponse(BaseModel):
id: str
revision: int
definition_hash: str
template_type: TemplateType
usages: list[str]
locale: str
required_fields: list[TemplateFieldRequirementModel]
output_profiles: list[TemplateOutputProfileModel]
content_text: str | None
content_html: str | None
layout: dict[str, Any]
metadata: dict[str, Any]
created_by_account_id: str | None
published_at: datetime | None
published_by_account_id: str | None
created_at: datetime
class TemplateResponse(BaseModel):
id: str
tenant_id: str
scope_type: str
scope_id: str | None
name: str
slug: str
description: str | None
template_type: TemplateType
status: str
current_revision: int
resource_revision: int
strong_etag: str
current_revision_id: str
published_revision_id: str | None
read_only: bool = False
metadata: dict[str, Any]
created_at: datetime
updated_at: datetime
revision: TemplateRevisionResponse
class TemplateListResponse(BaseModel):
items: list[TemplateResponse]
total: int
class TemplateCompatibilityResponse(BaseModel):
compatible: bool
template_id: str
revision_id: str
usage: str | None = None
output_format: str | None = None
missing_fields: list[str] = Field(default_factory=list)
incompatible_fields: list[str] = Field(default_factory=list)
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
class TemplateArtifactResponse(BaseModel):
kind: Literal["managed_file", "bounded_download"]
filename: str
content_type: str
size_bytes: int
sha256: str
file_asset_id: str | None = None
file_version_id: str | None = None
download_path: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class TemplateRenderResponse(BaseModel):
render_id: str
template_id: str
revision_id: str
revision: int
template_hash: str
input_hash: str
renderer_version: str
output_format: OutputFormat
content_type: str
filename: str
item_count: int
page_count: int
output_sha256: str
output_size_bytes: int
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
artifact: TemplateArtifactResponse | None = None
generated_at: datetime | None = None
class TemplateRenderListResponse(BaseModel):
items: list[TemplateRenderResponse]
total: int
+680
View File
@@ -0,0 +1,680 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from html import escape
from html.parser import HTMLParser
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.concurrency import claim_revision
from govoplan_core.core.templates import (
TemplateCompatibility,
TemplateCompatibilityError,
TemplateFieldRequirement,
TemplateNotFoundError,
TemplateOutputProfile,
TemplateRef,
TemplateRevisionRef,
)
from govoplan_core.security.time import utc_now
from govoplan_templates.backend.db.models import TemplateDefinition, TemplateRevision
from govoplan_templates.backend.schemas import TemplateCreateRequest, TemplateRevisionPayload, TemplateUpdateRequest
READ_SCOPE = "templates:template:read"
WRITE_SCOPE = "templates:template:write"
PUBLISH_SCOPE = "templates:template:publish"
RENDER_SCOPE = "templates:template:render"
ADMIN_SCOPE = "templates:template:admin"
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
def visible_templates_statement(principal: ApiPrincipal, *, include_deleted: bool = False):
statement = select(TemplateDefinition).where(
TemplateDefinition.tenant_id == principal.tenant_id
)
if not principal.has(ADMIN_SCOPE):
statement = statement.where(
or_(
TemplateDefinition.scope_type == "tenant",
(
(TemplateDefinition.scope_type == "user")
& (TemplateDefinition.scope_id == principal.account_id)
),
(
(TemplateDefinition.scope_type == "group")
& TemplateDefinition.scope_id.in_(tuple(principal.group_ids) or ("",))
),
)
)
if not include_deleted:
statement = statement.where(TemplateDefinition.deleted_at.is_(None))
return statement
def list_templates(
session: Session,
principal: ApiPrincipal,
*,
query: str = "",
usage: str | None = None,
template_type: str | None = None,
locale: str | None = None,
include_deleted: bool = False,
limit: int = 200,
) -> list[TemplateDefinition]:
statement = visible_templates_statement(principal, include_deleted=include_deleted)
normalized_query = query.strip()
if normalized_query:
pattern = f"%{normalized_query}%"
statement = statement.where(
or_(
TemplateDefinition.name.ilike(pattern),
TemplateDefinition.slug.ilike(pattern),
TemplateDefinition.description.ilike(pattern),
)
)
if template_type:
statement = statement.where(TemplateDefinition.template_type == template_type)
rows = list(
session.scalars(
statement.order_by(TemplateDefinition.name, TemplateDefinition.id).limit(
max(1, min(limit, 500))
)
)
)
if not usage and not locale:
return rows
filtered: list[TemplateDefinition] = []
for item in rows:
revision = get_template_revision(session, item, published_preferred=True)
if usage and usage.strip().lower() not in revision.usages:
continue
if locale and revision.locale.lower() != locale.lower():
continue
filtered.append(item)
return filtered
def get_template(
session: Session,
principal: ApiPrincipal,
template_id: str,
*,
include_deleted: bool = False,
) -> TemplateDefinition:
item = session.scalar(
visible_templates_statement(principal, include_deleted=include_deleted).where(
TemplateDefinition.id == template_id
)
)
if item is None:
raise TemplateNotFoundError("Template not found.")
return item
def get_template_revision(
session: Session,
definition: TemplateDefinition,
*,
revision: int | None = None,
published_preferred: bool = False,
) -> TemplateRevision:
if revision is not None:
revision_number = revision
elif published_preferred and definition.published_revision_id:
published = session.get(TemplateRevision, definition.published_revision_id)
if published is not None and published.template_id == definition.id:
return published
revision_number = definition.current_revision
else:
revision_number = definition.current_revision
item = session.scalar(
select(TemplateRevision).where(
TemplateRevision.template_id == definition.id,
TemplateRevision.tenant_id == definition.tenant_id,
TemplateRevision.revision == revision_number,
)
)
if item is None:
raise TemplateNotFoundError("Template revision not found.")
return item
def list_template_revisions(
session: Session,
definition: TemplateDefinition,
) -> list[TemplateRevision]:
return list(
session.scalars(
select(TemplateRevision)
.where(
TemplateRevision.tenant_id == definition.tenant_id,
TemplateRevision.template_id == definition.id,
)
.order_by(TemplateRevision.revision.desc())
.limit(500)
)
)
def create_template(
session: Session,
principal: ApiPrincipal,
payload: TemplateCreateRequest,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_requested_scope(principal, payload.scope_type, payload.scope_id)
slug = _slug(payload.slug or payload.name)
_ensure_unique_slug(
session,
principal,
slug=slug,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
definition = TemplateDefinition(
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
name=payload.name.strip(),
slug=slug,
description=_text(payload.description),
template_type=payload.template_type,
status="draft",
current_revision_id="pending",
current_revision=1,
resource_revision=1,
created_by_account_id=principal.account_id,
updated_by_account_id=principal.account_id,
metadata_={},
)
session.add(definition)
session.flush()
revision = _create_revision(
session,
principal,
definition=definition,
revision_number=1,
payload=payload,
)
definition.current_revision_id = revision.id
session.flush()
return definition, revision
def update_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
payload: TemplateUpdateRequest,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_mutable_scope(principal, definition)
_ensure_requested_scope(principal, payload.scope_type, payload.scope_id)
slug = _slug(payload.slug or payload.name)
_ensure_unique_slug(
session,
principal,
slug=slug,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
exclude_id=definition.id,
)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=payload.base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path=f"/api/v1/templates/{definition.id}",
)
definition.resource_revision = next_resource_revision
definition.scope_type = payload.scope_type
definition.scope_id = payload.scope_id
definition.name = payload.name.strip()
definition.slug = slug
definition.description = _text(payload.description)
definition.template_type = payload.template_type
definition.current_revision += 1
definition.current_revision_id = "pending"
definition.status = "draft"
definition.updated_by_account_id = principal.account_id
revision = _create_revision(
session,
principal,
definition=definition,
revision_number=definition.current_revision,
payload=payload,
)
definition.current_revision_id = revision.id
session.flush()
return definition, revision
def publish_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
*,
revision: int | None,
base_revision: int,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_mutable_scope(principal, definition)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path=f"/api/v1/templates/{definition.id}",
)
item_revision = get_template_revision(session, definition, revision=revision)
now = utc_now()
item_revision.published_at = now
item_revision.published_by_account_id = principal.account_id
definition.published_revision_id = item_revision.id
definition.status = "active"
definition.resource_revision = next_resource_revision
definition.updated_by_account_id = principal.account_id
session.add(item_revision)
session.add(definition)
session.flush()
return definition, item_revision
def delete_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
*,
base_revision: int,
) -> TemplateDefinition:
_ensure_mutable_scope(principal, definition)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path="/api/v1/templates",
)
definition.resource_revision = next_resource_revision
definition.status = "deleted"
definition.deleted_at = utc_now()
definition.updated_by_account_id = principal.account_id
session.flush()
return definition
def compatibility(
revision: TemplateRevision,
*,
usage: str | None,
output_format: str | None,
available_fields: Mapping[str, str] | Sequence[str],
) -> TemplateCompatibility:
field_types = (
{str(key): str(value) for key, value in available_fields.items()}
if isinstance(available_fields, Mapping)
else {str(item): "unknown" for item in available_fields}
)
normalized_usage = usage.strip().lower() if usage else None
diagnostics: list[dict[str, object]] = []
missing: list[str] = []
incompatible: list[str] = []
if normalized_usage and normalized_usage not in revision.usages:
diagnostics.append(
{
"code": "template.usage_incompatible",
"severity": "error",
"message": f"This template is not published for {normalized_usage}.",
}
)
formats = {str(item.get("output_format")) for item in revision.output_profiles}
if output_format and output_format not in formats:
diagnostics.append(
{
"code": "template.output_format_incompatible",
"severity": "error",
"message": f"This template does not provide {output_format} output.",
}
)
for requirement in revision.required_fields:
path = str(requirement.get("path") or "")
if not path or not bool(requirement.get("required", True)):
continue
if path not in field_types:
missing.append(path)
continue
actual = field_types[path]
expected = str(requirement.get("value_type") or "string")
if actual not in {"unknown", expected} and not (
expected == "number" and actual in {"integer", "number"}
):
incompatible.append(path)
if missing:
diagnostics.append(
{
"code": "template.required_fields_missing",
"severity": "error",
"message": f"Missing required fields: {', '.join(sorted(missing))}.",
}
)
if incompatible:
diagnostics.append(
{
"code": "template.field_types_incompatible",
"severity": "error",
"message": f"Fields have incompatible types: {', '.join(sorted(incompatible))}.",
}
)
return TemplateCompatibility(
compatible=not diagnostics,
template_id=revision.template_id,
revision_id=revision.id,
usage=normalized_usage,
output_format=output_format,
missing_fields=tuple(sorted(missing)),
incompatible_fields=tuple(sorted(incompatible)),
diagnostics=tuple(diagnostics),
)
def template_ref(
definition: TemplateDefinition,
revision: TemplateRevision,
*,
read_only: bool = False,
) -> TemplateRef:
return TemplateRef(
id=definition.id,
tenant_id=definition.tenant_id,
name=definition.name,
slug=definition.slug,
template_type=definition.template_type, # type: ignore[arg-type]
status=definition.status,
current_revision=definition.current_revision,
current_revision_id=definition.current_revision_id,
published_revision_id=definition.published_revision_id,
description=definition.description,
scope_type=definition.scope_type,
scope_id=definition.scope_id,
read_only=read_only,
updated_at=definition.updated_at,
revision=revision_ref(revision),
metadata=dict(definition.metadata_ or {}),
)
def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
return TemplateRevisionRef(
id=revision.id,
template_id=revision.template_id,
revision=revision.revision,
definition_hash=revision.definition_hash,
template_type=revision.template_type, # type: ignore[arg-type]
usages=tuple(revision.usages or []),
locale=revision.locale,
required_fields=tuple(
TemplateFieldRequirement(**item) for item in revision.required_fields
),
output_profiles=tuple(
TemplateOutputProfile(**item) for item in revision.output_profiles
),
content_text=revision.content_text,
content_html=revision.content_html,
layout=dict(revision.layout or {}),
metadata=dict(revision.metadata_ or {}),
published_at=revision.published_at,
provenance={
"module": "templates",
"created_by_account_id": revision.created_by_account_id,
},
)
def referenced_fields(revision: TemplateRevision) -> tuple[str, ...]:
content = f"{revision.content_text or ''}\n{revision.content_html or ''}"
return tuple(sorted(set(_TOKEN_PATTERN.findall(content))))
def _create_revision(
session: Session,
principal: ApiPrincipal,
*,
definition: TemplateDefinition,
revision_number: int,
payload: TemplateRevisionPayload,
) -> TemplateRevision:
output_profiles = [item.model_dump(mode="json") for item in payload.output_profiles]
if not output_profiles:
output_profiles = _default_output_profiles(payload.template_type)
sanitized_html = sanitize_template_html(payload.content_html)
definition_payload = {
"template_type": payload.template_type,
"usages": payload.usages,
"locale": payload.locale,
"required_fields": [item.model_dump(mode="json") for item in payload.required_fields],
"output_profiles": output_profiles,
"content_text": payload.content_text,
"content_html": sanitized_html,
"layout": payload.layout,
"metadata": payload.metadata,
}
revision = TemplateRevision(
tenant_id=principal.tenant_id,
template_id=definition.id,
revision=revision_number,
definition_hash=_canonical_hash(definition_payload),
template_type=payload.template_type,
usages=list(payload.usages),
locale=payload.locale,
required_fields=definition_payload["required_fields"],
output_profiles=output_profiles,
content_text=payload.content_text,
content_html=sanitized_html,
layout=dict(payload.layout),
metadata_=dict(payload.metadata),
created_by_account_id=principal.account_id,
)
session.add(revision)
session.flush()
return revision
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
if template_type == "content_fragment":
return []
media = "A4"
if template_type == "envelope":
media = "DL"
return [
{
"id": "print-html",
"label": "Printable HTML",
"output_format": "html",
"media_type": "text/html",
"channel": "print",
"capabilities": ["browser_print", template_type],
"page": {"size": media},
},
{
"id": "plain-text",
"label": "Plain text",
"output_format": "text",
"media_type": "text/plain",
"channel": "download",
"capabilities": ["deterministic_text"],
"page": {},
},
]
def _ensure_unique_slug(
session: Session,
principal: ApiPrincipal,
*,
slug: str,
scope_type: str,
scope_id: str | None,
exclude_id: str | None = None,
) -> None:
statement = select(TemplateDefinition.id).where(
TemplateDefinition.tenant_id == principal.tenant_id,
TemplateDefinition.scope_type == scope_type,
TemplateDefinition.slug == slug,
TemplateDefinition.deleted_at.is_(None),
)
if scope_id is None:
statement = statement.where(TemplateDefinition.scope_id.is_(None))
else:
statement = statement.where(TemplateDefinition.scope_id == scope_id)
if exclude_id:
statement = statement.where(TemplateDefinition.id != exclude_id)
if session.scalar(statement) is not None:
raise TemplateCompatibilityError("A template with this slug already exists in the selected scope.")
def _ensure_requested_scope(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> None:
if principal.has(ADMIN_SCOPE):
return
if scope_type == "tenant":
return
if scope_type == "user" and scope_id == principal.account_id:
return
if scope_type == "group" and scope_id in principal.group_ids:
return
raise TemplateCompatibilityError("The requested template scope is not writable by this principal.")
def _ensure_mutable_scope(principal: ApiPrincipal, definition: TemplateDefinition) -> None:
_ensure_requested_scope(principal, definition.scope_type, definition.scope_id)
def _slug(value: str) -> str:
normalized = re.sub(r"[^a-z0-9_.-]+", "-", value.strip().lower()).strip("-.")
if not normalized:
raise TemplateCompatibilityError("Template slug cannot be empty.")
return normalized[:160]
def _text(value: str | None) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _canonical_hash(value: object) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class _TemplateHtmlSanitizer(HTMLParser):
allowed_tags = {
"a", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "li", "ol", "p", "pre", "span", "strong",
"table", "tbody", "td", "th", "thead", "tr", "u", "ul",
}
void_tags = {"br", "hr"}
blocked_tags = {"script", "style", "iframe", "object", "embed", "svg", "math"}
allowed_attrs = {"a": {"href", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan"}}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.blocked_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if tag in self.blocked_tags:
self.blocked_depth += 1
return
if self.blocked_depth or tag not in self.allowed_tags:
return
clean_attrs: list[str] = []
for name, raw_value in attrs:
name = name.lower()
value = str(raw_value or "")
if name not in self.allowed_attrs.get(tag, set()):
continue
if name == "href" and not _safe_href(value):
continue
clean_attrs.append(f' {name}="{escape(value, quote=True)}"')
self.parts.append(f"<{tag}{''.join(clean_attrs)}>")
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in self.blocked_tags:
self.blocked_depth = max(0, self.blocked_depth - 1)
return
if not self.blocked_depth and tag in self.allowed_tags and tag not in self.void_tags:
self.parts.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
if not self.blocked_depth:
self.parts.append(escape(data, quote=False))
def sanitize_template_html(value: str | None) -> str | None:
if not value or not value.strip():
return None
parser = _TemplateHtmlSanitizer()
parser.feed(value)
parser.close()
return "".join(parser.parts).strip() or None
def _safe_href(value: str) -> bool:
normalized = value.strip().lower()
return normalized.startswith(("https://", "http://", "mailto:", "#", "/"))
__all__ = [
"ADMIN_SCOPE",
"PUBLISH_SCOPE",
"READ_SCOPE",
"RENDER_SCOPE",
"WRITE_SCOPE",
"compatibility",
"create_template",
"delete_template",
"get_template",
"get_template_revision",
"list_template_revisions",
"list_templates",
"publish_template",
"referenced_fields",
"revision_ref",
"sanitize_template_html",
"template_ref",
"update_template",
]
+1
View File
@@ -0,0 +1 @@
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
from govoplan_templates.backend.dsar_provider import (
TEMPLATES_DSAR_CAPABILITY,
TemplatesDsarProvider,
)
from govoplan_templates.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 14, 0, tzinfo=UTC)
class TemplatesDsarProviderTests(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 = TemplatesDsarProvider()
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add(
TemplateDefinition(
id="template-1",
tenant_id="tenant-1",
scope_type="tenant",
name="Sensitive template title do not export",
slug="secret-slug-do-not-export",
description="description-do-not-export",
template_type="letter",
status="published",
current_revision_id="revision-1",
current_revision=1,
published_revision_id="revision-1",
resource_revision=2,
created_by_account_id="account-1",
updated_by_account_id="account-1",
metadata_={"secret": "definition-metadata-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
)
self.session.add(
TemplateRevision(
id="revision-1",
tenant_id="tenant-1",
template_id="template-1",
revision=1,
definition_hash="definition-hash-do-not-export",
template_type="letter",
usages=["campaign"],
locale="de",
required_fields=[{"secret": "required-field-do-not-export"}],
output_profiles=[{"secret": "output-profile-do-not-export"}],
content_text="template-text-do-not-export",
content_html="template-html-do-not-export",
layout={"secret": "layout-do-not-export"},
metadata_={"secret": "revision-metadata-do-not-export"},
created_by_account_id="account-1",
published_at=NOW,
published_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
)
self.session.add(
TemplateRender(
id="render-1",
tenant_id="tenant-1",
template_id="template-1",
revision_id="revision-1",
revision_number=1,
mode="final",
usage="campaign",
output_format="html",
content_type="text/html",
filename="personal-filename-do-not-export.html",
idempotency_key="render-idempotency-do-not-export",
template_hash="template-hash-do-not-export",
input_hash="input-hash-do-not-export",
renderer_version="renderer-v1",
output_sha256="output-hash-do-not-export",
output_size_bytes=123,
item_count=2,
page_count=1,
diagnostics=[{"secret": "diagnostic-do-not-export"}],
input_snapshot={"person": "input-person-do-not-export"},
artifact_ref={"secret": "artifact-ref-do-not-export"},
payload=b"output-payload-do-not-export",
created_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
)
def test_search_is_minimized_and_narrowable(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(3, len(records))
exported = json.dumps([record.to_dict() for record in records])
for excluded in (
"Sensitive template title do not export",
"secret-slug-do-not-export",
"definition-metadata-do-not-export",
"definition-hash-do-not-export",
"required-field-do-not-export",
"template-text-do-not-export",
"template-html-do-not-export",
"personal-filename-do-not-export",
"render-idempotency-do-not-export",
"template-hash-do-not-export",
"input-person-do-not-export",
"artifact-ref-do-not-export",
"output-payload-do-not-export",
):
self.assertNotIn(excluded, exported)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"templates.render": "render-1"},
),
)
self.assertEqual(
{"template_render_actor_attribution"},
{record.resource_type for record in narrowed},
)
def test_account_is_required_and_records_are_retained(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="author@example.test"),
),
)
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(TEMPLATES_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"templates.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,47 @@
from __future__ import annotations
import unittest
from govoplan_templates.backend.manifest import manifest
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual({"/templates"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
self.assertEqual(
{
"templates.page",
"templates.library",
"templates.editor",
"templates.preview",
},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
library = topics["templates.library"]
output = topics["templates.printable-output"]
reference = topics["templates.reference.fields-and-consequences"]
self.assertIn("templates.state.read-only", library.metadata["help_contexts"])
self.assertEqual("workflow", library.metadata["kind"])
self.assertIn("templates.action.render-final", output.metadata["help_contexts"])
self.assertIn("templates.field.usages", reference.metadata["help_contexts"])
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
self.assertIn("delete_template", reference.metadata["consequence_classes"])
self.assertEqual("reference", reference.metadata["kind"])
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.core.templates import TemplateRenderError
from govoplan_templates.backend import rendering
class TemplateRenderLimitTests(unittest.TestCase):
def test_many_items_stop_before_rendering_the_entire_oversized_bundle(self):
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_substitute", wraps=rendering._substitute) as substitute:
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
rendering._render_items("x" * 64, {}, [{}] * 5000, html=False)
self.assertEqual(3, substitute.call_count)
def test_repeated_token_expansion_stops_before_allocating_the_whole_row(self):
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_display_value", wraps=rendering._display_value) as display:
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
rendering._substitute("{{value}}" * 5000, {"value": "x" * 64}, html=False)
self.assertEqual(3, display.call_count)
def test_utf8_byte_limit_is_not_a_character_limit(self):
with patch.object(rendering, "MAX_OUTPUT_BYTES", 4):
self.assertEqual("üü", rendering._substitute("{{value}}", {"value": "üü"}, html=False))
with self.assertRaises(TemplateRenderError):
rendering._substitute("{{value}}", {"value": "üüü"}, html=False)
def test_html_escaping_counts_expanded_bytes_and_preserves_valid_output(self):
with patch.object(rendering, "MAX_OUTPUT_BYTES", 9):
self.assertEqual("&lt;&amp;", rendering._substitute("{{value}}", {"value": "<&"}, html=True))
with self.assertRaises(TemplateRenderError):
rendering._substitute("{{value}}!", {"value": "<&"}, html=True)
def test_text_separator_is_part_of_the_same_budget(self):
with patch.object(rendering, "MAX_OUTPUT_BYTES", 5):
self.assertEqual(["ab", "cd"], rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n"))
with self.assertRaises(TemplateRenderError):
rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n\n")
def test_html_wrapper_is_also_subject_to_output_limit(self):
definition = SimpleNamespace(name="Example")
revision = SimpleNamespace(content_html="<p>Ada</p>", content_text=None, template_type="list_layout", layout={}, output_profiles=[])
request = SimpleNamespace(output_format="html", parameters={})
with patch.object(rendering, "MAX_OUTPUT_BYTES", 32):
with self.assertRaises(TemplateRenderError):
rendering._render_payload(definition, revision, request=request, items=[{}])
if __name__ == "__main__":
unittest.main()
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import hashlib
import unittest
from unittest.mock import patch
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.files import ManagedArtifactRef
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCompatibilityError,
TemplateContentDraftRequest,
TemplateFieldRequirement,
TemplateRenderError,
TemplateRenderRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_templates.backend.capabilities import (
SqlTemplateCatalog,
SqlTemplateContentLibrary,
)
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
from govoplan_templates.backend.rendering import (
get_render_for_principal,
list_renders,
render_template,
)
from govoplan_templates.backend.schemas import (
TemplateCreateRequest,
TemplateUpdateRequest,
)
from govoplan_templates.backend.service import (
create_template,
publish_template,
sanitize_template_html,
update_template,
)
def principal(
tenant_id: str = "tenant-1",
*,
account_id: str = "account-1",
admin: bool = True,
) -> ApiPrincipal:
scopes = {
"templates:template:read",
"templates:template:write",
"templates:template:publish",
"templates:template:render",
"files:file:upload",
}
if admin:
scopes.add("templates:template:admin")
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id="membership-1",
tenant_id=tenant_id,
identity_id="identity-1",
scopes=frozenset(scopes),
),
account=object(),
user=type("User", (), {"id": "user-1"})(),
)
def payload(
name: str = "Postal letter",
*,
template_type: str = "serial_letter",
body: str = "<p>Hello {{name}}</p><p>{{postal.address}}</p>",
) -> TemplateCreateRequest:
return TemplateCreateRequest.model_validate(
{
"name": name,
"template_type": template_type,
"usages": ["campaign.postal"],
"locale": "de-DE",
"required_fields": [
{"path": "name", "value_type": "string", "required": True},
{"path": "postal.address", "value_type": "string", "required": True},
],
"content_html": body,
"layout": {
"page_size": "A4",
"margin_mm": 15,
"columns": 2,
"rows": 2,
},
}
)
class _Registry:
def __init__(self, capability=None) -> None:
self._capability = capability
def has_capability(self, name: str) -> bool:
return self._capability is not None and name == "files.artifact_store"
def capability(self, name: str):
return self._capability if name == "files.artifact_store" else None
class _ArtifactStore:
def __init__(self) -> None:
self.request = None
def store_artifact(self, session, principal, *, request):
del session, principal
self.request = request
return ManagedArtifactRef(
file_asset_id="file-1",
file_version_id="version-1",
filename=request.filename,
display_path=f"Generated/Templates/{request.filename}",
content_type=request.content_type,
size_bytes=len(request.payload),
sha256=hashlib.sha256(request.payload).hexdigest(),
provenance={"module": "files", "managed": True},
)
class TemplateServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[
TemplateDefinition.__table__,
TemplateRevision.__table__,
TemplateRender.__table__,
],
)
def tearDown(self) -> None:
reset_database(dispose=True)
def test_catalogue_exposes_typed_contract_and_immutable_revisions(self) -> None:
with self.database.session() as session:
item, first = create_template(session, principal(), payload())
update = TemplateUpdateRequest.model_validate(
{
**payload(body="<p>Dear {{name}}</p><p>{{postal.address}}</p>").model_dump(mode="json"),
"base_revision": 1,
}
)
item, second = update_template(session, principal(), item, update)
session.commit()
refs = SqlTemplateCatalog().list_templates(
session,
principal(),
usage="campaign.postal",
)
self.assertEqual(1, len(refs))
self.assertEqual("serial_letter", refs[0].template_type)
self.assertEqual(("campaign.postal",), refs[0].revision.usages)
self.assertIn("Dear", refs[0].revision.content_html)
self.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
self.assertNotEqual(first.definition_hash, second.definition_hash)
self.assertEqual(2, second.revision)
def test_content_library_creates_unpublished_provider_owned_draft(self) -> None:
with self.database.session() as session, patch(
"govoplan_templates.backend.capabilities.audit_from_principal"
), patch("govoplan_templates.backend.capabilities.emit_platform_event"):
result = SqlTemplateContentLibrary().create_content_draft(
session,
principal(),
request=TemplateContentDraftRequest(
name="Closing paragraph",
template_type="content_fragment",
usages=("campaign.content",),
locale="de",
content_text="Mit freundlichen Grüßen",
required_fields=(
TemplateFieldRequirement(
path="local.display_name",
label="Display name",
),
),
metadata={"campaign_targets": ["text"]},
),
)
session.commit()
self.assertEqual("draft", result.status)
self.assertEqual("content_fragment", result.template_type)
self.assertEqual("Mit freundlichen Grüßen", result.revision.content_text)
self.assertEqual(
"local.display_name",
result.revision.required_fields[0].path,
)
self.assertEqual(["text"], result.revision.metadata["campaign_targets"])
self.assertEqual(
"templates.content_library",
result.revision.metadata["created_through"],
)
def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
frozen = (
{"name": "Ada", "postal": {"address": "Street 1"}},
{"name": "Grace", "postal": {"address": "Street 2"}},
)
with self.database.session() as session:
item, _ = create_template(session, principal(), payload())
item, revision = publish_template(
session,
principal(),
item,
revision=1,
base_revision=1,
)
request = TemplateRenderRequest(
template_id=item.id,
revision=revision.revision,
usage="campaign.postal",
items=frozen,
input_snapshot={"provider": "dist_lists", "snapshot_id": "snapshot-1"},
mode="final",
idempotency_key="campaign-1:postal-output-1",
)
first = render_template(session, principal(), registry=_Registry(), request=request)
second = render_template(session, principal(), registry=_Registry(), request=request)
session.commit()
self.assertEqual(first.render_id, second.render_id)
self.assertEqual(first.input_hash, second.input_hash)
self.assertEqual(first.output_sha256, second.output_sha256)
self.assertEqual(2, first.item_count)
self.assertEqual(2, first.page_count)
self.assertEqual("bounded_download", first.artifact.kind)
self.assertIn(b"Ada", first.payload)
self.assertIn(b"Grace", first.payload)
def test_bounded_render_history_and_payload_are_owner_scoped(self) -> None:
owner = principal(admin=False)
other = principal(account_id="account-2", admin=False)
administrator = principal(account_id="account-admin")
with self.database.session() as session:
item, _ = create_template(session, owner, payload())
result = render_template(
session,
owner,
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=(
{
"name": "Ada",
"postal": {"address": "Street 1"},
},
),
),
)
session.commit()
self.assertEqual(
result.render_id,
get_render_for_principal(session, owner, result.render_id).id,
)
with self.assertRaisesRegex(TemplateRenderError, "not found"):
get_render_for_principal(session, other, result.render_id)
self.assertEqual([], list_renders(session, other))
self.assertEqual(
result.render_id,
get_render_for_principal(
session,
administrator,
result.render_id,
).id,
)
def test_label_sheet_page_count_and_missing_fields(self) -> None:
with self.database.session() as session:
item, _ = create_template(
session,
principal(),
payload("Address labels", template_type="label_sheet"),
)
with self.assertRaises(TemplateCompatibilityError):
render_template(
session,
principal(),
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=({"name": "Missing address"},),
),
)
result = render_template(
session,
principal(),
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=tuple(
{"name": f"Person {index}", "postal": {"address": f"Street {index}"}}
for index in range(5)
),
),
)
self.assertEqual(2, result.page_count)
def test_optional_files_store_receives_hashes_without_templates_payload_copy(self) -> None:
store = _ArtifactStore()
with self.database.session() as session:
item, _ = create_template(session, principal(), payload())
item, revision = publish_template(session, principal(), item, revision=1, base_revision=1)
result = render_template(
session,
principal(),
registry=_Registry(store),
request=TemplateRenderRequest(
template_id=item.id,
revision=revision.revision,
usage="campaign.postal",
items=({"name": "Ada", "postal": {"address": "Street 1"}},),
input_snapshot={"snapshot_id": "snapshot-1"},
mode="final",
idempotency_key="managed-output-1",
persist_to_files=True,
),
)
session.commit()
row = session.get(TemplateRender, result.render_id)
self.assertEqual("managed_file", result.artifact.kind)
self.assertIsNone(row.payload)
self.assertEqual(result.output_sha256, store.request.metadata["output_sha256"])
self.assertNotIn("Ada", str(store.request.metadata))
def test_tenant_isolation_and_html_sanitization(self) -> None:
self.assertEqual("<p>Safe</p>", sanitize_template_html("<p>Safe</p><script>alert(1)</script>"))
self.assertEqual("<a>Unsafe</a>", sanitize_template_html('<a href="javascript:alert(1)">Unsafe</a>'))
with self.database.session() as session:
item, _ = create_template(session, principal("tenant-1"), payload())
session.commit()
self.assertIsNone(
SqlTemplateCatalog().get_template(
session,
principal("tenant-2"),
template_id=item.id,
)
)
class TemplateManifestTests(unittest.TestCase):
def test_manifest_announces_provider_neutral_capabilities(self) -> None:
from govoplan_templates.backend.manifest import get_manifest
manifest = get_manifest()
self.assertEqual("templates", manifest.id)
self.assertFalse(manifest.dependencies)
self.assertIn("files", manifest.optional_dependencies)
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories)
self.assertIn(
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
manifest.capability_factories,
)
self.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/templates-webui",
"version": "0.1.22",
"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/templates.css": "./src/styles/templates.css"
},
"scripts": {
"test:dialog-layout": "node scripts/test-dialog-layout.mjs",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(new URL("../src/features/templates/TemplatesPage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/styles/templates.css", import.meta.url), "utf8");
assert.match(page, /<Dialog open=\{createOpen\}[^]*?<FormGrid columns=\{2\} collapseAt="standard">/);
assert.doesNotMatch(page, /templates-dialog-form/);
assert.doesNotMatch(styles, /templates-dialog-form/);
console.log("Templates dialog uses the shared shrinking form layout.");
+267
View File
@@ -0,0 +1,267 @@
import {
apiDownload,
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type TemplateType =
| "label"
| "label_sheet"
| "envelope"
| "serial_letter"
| "form_letter"
| "list_layout"
| "email"
| "content_fragment"
| "generic";
export type TemplateFieldType =
| "string"
| "integer"
| "number"
| "boolean"
| "date"
| "datetime"
| "object"
| "array";
export type TemplateFieldRequirement = {
path: string;
value_type: TemplateFieldType;
label?: string | null;
required: boolean;
description?: string | null;
};
export type TemplateOutputProfile = {
id: string;
label: string;
output_format: "html" | "text";
media_type: string;
channel: string;
capabilities: string[];
page: Record<string, unknown>;
};
export type TemplateRevision = {
id: string;
revision: number;
definition_hash: string;
template_type: TemplateType;
usages: string[];
locale: string;
required_fields: TemplateFieldRequirement[];
output_profiles: TemplateOutputProfile[];
content_text?: string | null;
content_html?: string | null;
layout: Record<string, unknown>;
metadata: Record<string, unknown>;
created_by_account_id?: string | null;
published_at?: string | null;
published_by_account_id?: string | null;
created_at: string;
};
export type TemplateDefinition = {
id: string;
tenant_id: string;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
template_type: TemplateType;
status: string;
current_revision: number;
resource_revision: number;
strong_etag: string;
current_revision_id: string;
published_revision_id?: string | null;
read_only: boolean;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
revision: TemplateRevision;
};
export type TemplatePayload = {
name: string;
slug?: string | null;
description?: string | null;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
template_type: TemplateType;
usages: string[];
locale: string;
required_fields: TemplateFieldRequirement[];
output_profiles: TemplateOutputProfile[];
content_text?: string | null;
content_html?: string | null;
layout: Record<string, unknown>;
metadata: Record<string, unknown>;
};
export type TemplateCompatibility = {
compatible: boolean;
template_id: string;
revision_id: string;
usage?: string | null;
output_format?: string | null;
missing_fields: string[];
incompatible_fields: string[];
diagnostics: Array<Record<string, unknown>>;
};
export type TemplateArtifact = {
kind: "managed_file" | "bounded_download";
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
file_asset_id?: string | null;
file_version_id?: string | null;
download_path?: string | null;
provenance: Record<string, unknown>;
};
export type TemplateRender = {
render_id: string;
template_id: string;
revision_id: string;
revision: number;
template_hash: string;
input_hash: string;
renderer_version: string;
output_format: "html" | "text";
content_type: string;
filename: string;
item_count: number;
page_count: number;
output_sha256: string;
output_size_bytes: number;
diagnostics: Array<Record<string, unknown>>;
artifact?: TemplateArtifact | null;
generated_at?: string | null;
};
export async function listTemplates(settings: ApiSettings): Promise<TemplateDefinition[]> {
const result = await apiFetch<{ items: TemplateDefinition[] }>(
settings,
apiPath("/api/v1/templates", { limit: 500 })
);
return result.items;
}
export function listTemplateRevisions(
settings: ApiSettings,
templateId: string
): Promise<TemplateRevision[]> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(templateId)}/revisions`);
}
export async function listTemplateRenders(
settings: ApiSettings,
templateId: string
): Promise<TemplateRender[]> {
const result = await apiFetch<{ items: TemplateRender[] }>(
settings,
apiPath("/api/v1/templates/renders/history", { template_id: templateId, limit: 100 })
);
return result.items;
}
export function createTemplate(settings: ApiSettings, payload: TemplatePayload): Promise<TemplateDefinition> {
return apiFetch(settings, "/api/v1/templates", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateTemplate(
settings: ApiSettings,
item: TemplateDefinition,
payload: TemplatePayload
): Promise<TemplateDefinition> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
method: "PUT",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ ...payload, base_revision: item.resource_revision })
});
}
export function publishTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<TemplateDefinition> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/publish`, {
method: "POST",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ revision: item.current_revision, base_revision: item.resource_revision })
});
}
export function deleteTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<void> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
method: "DELETE",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ base_revision: item.resource_revision })
});
}
export function checkTemplateCompatibility(
settings: ApiSettings,
item: TemplateDefinition,
usage: string,
availableFields: Record<string, string>,
outputFormat: "html" | "text"
): Promise<TemplateCompatibility> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/compatibility`, {
method: "POST",
body: JSON.stringify({
revision: item.current_revision,
usage: usage || null,
output_format: outputFormat,
available_fields: availableFields
})
});
}
export function renderTemplate(
settings: ApiSettings,
item: TemplateDefinition,
options: {
usage: string;
outputFormat: "html" | "text";
items: Array<Record<string, unknown>>;
final: boolean;
persistToFiles: boolean;
}
): Promise<TemplateRender> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/render`, {
method: "POST",
body: JSON.stringify({
revision: item.current_revision,
usage: options.usage || null,
output_format: options.outputFormat,
items: options.items,
input_snapshot: {
source: "templates.webui",
supplied_item_count: options.items.length
},
mode: options.final ? "final" : "preview",
idempotency_key: options.final ? `templates-ui:${crypto.randomUUID()}` : null,
persist_to_files: options.persistToFiles
})
});
}
export function downloadTemplateRender(settings: ApiSettings, render: TemplateRender): Promise<void> {
if (render.artifact?.kind === "bounded_download") {
return apiDownload(
settings,
`/api/v1/templates/renders/${encodeURIComponent(render.render_id)}/download`,
render.filename
);
}
const path = render.artifact?.download_path;
if (!path) return Promise.reject(new Error("This render has no downloadable artifact."));
return apiDownload(settings, path, render.filename);
}
@@ -0,0 +1,659 @@
import {
Download,
Eye,
FileCheck2,
Plus,
Save,
Send,
Trash2,
X
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormGrid, ActionToolbar,
ApiError,
ActionBlockerHint,
Button,
ConfirmDialog,
ContentSection,
Dialog,
DialogSection,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
FormField,
IconButton,
LoadingFrame,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime,
hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { WysiwygEditor } from "@govoplan/core-webui/wysiwyg";
import {
checkTemplateCompatibility,
createTemplate,
deleteTemplate,
downloadTemplateRender,
listTemplateRenders,
listTemplateRevisions,
listTemplates,
publishTemplate,
renderTemplate,
updateTemplate,
type TemplateCompatibility,
type TemplateDefinition,
type TemplateFieldRequirement,
type TemplateFieldType,
type TemplatePayload,
type TemplateRender,
type TemplateRevision,
type TemplateType
} from "../../api/templates";
import {
TEMPLATE_FIELDS_DOCUMENTATION,
TEMPLATE_OUTPUT_DOCUMENTATION,
TEMPLATES_DOCUMENTATION,
TEMPLATES_I18N
} from "./interfacePatterns";
type Props = { settings: ApiSettings; auth: AuthInfo };
type WorkspaceView = "definition" | "preview";
const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
{ value: "label", label: "Label" },
{ value: "label_sheet", label: "Label sheet" },
{ value: "envelope", label: "Envelope" },
{ value: "serial_letter", label: "Serial letter" },
{ value: "form_letter", label: "Form letter" },
{ value: "list_layout", label: "List layout" },
{ value: "email", label: "Email" },
{ value: "content_fragment", label: "Content fragment" },
{ value: "generic", label: "Generic" }
];
const FIELD_TYPES: TemplateFieldType[] = [
"string", "integer", "number", "boolean", "date", "datetime", "object", "array"
];
export default function TemplatesPage({ settings, auth }: Props) {
const [items, setItems] = useState<TemplateDefinition[]>([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<TemplatePayload>(emptyPayload());
const [savedKey, setSavedKey] = useState("");
const [view, setView] = useState<WorkspaceView>("definition");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createType, setCreateType] = useState<TemplateType>("form_letter");
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(false);
const [finalRenderOpen, setFinalRenderOpen] = useState(false);
const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}');
const [usage, setUsage] = useState("campaign.postal");
const [outputFormat, setOutputFormat] = useState<"html" | "text">("html");
const [persistToFiles, setPersistToFiles] = useState(false);
const [compatibility, setCompatibility] = useState<TemplateCompatibility | null>(null);
const [render, setRender] = useState<TemplateRender | null>(null);
const [revisions, setRevisions] = useState<TemplateRevision[]>([]);
const [renders, setRenders] = useState<TemplateRender[]>([]);
const { requestDiscard } = useUnsavedChanges();
const selected = items.find((item) => item.id === selectedId) ?? null;
const canWrite = hasScope(auth, "templates:template:write") || hasScope(auth, "templates:template:admin");
const canPublish = hasScope(auth, "templates:template:publish") || hasScope(auth, "templates:template:admin");
const canRender = hasScope(auth, "templates:template:render") || hasScope(auth, "templates:template:admin");
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
const readOnly = !canWrite || Boolean(selected?.read_only);
const applyItem = useCallback((item: TemplateDefinition | null) => {
const next = item ? payloadFromItem(item) : emptyPayload();
setDraft(next);
setSavedKey(item ? draftKey(next) : "");
setCompatibility(null);
setRender(null);
setUsage(item?.revision.usages[0] ?? "campaign.postal");
}, []);
const reload = useCallback(async (preferredId?: string) => {
setLoading(true);
setError("");
try {
const nextItems = await listTemplates(settings);
setItems(nextItems);
const nextId = preferredId && nextItems.some((item) => item.id === preferredId)
? preferredId
: nextItems.some((item) => item.id === selectedId)
? selectedId
: nextItems[0]?.id ?? "";
setSelectedId(nextId);
applyItem(nextItems.find((item) => item.id === nextId) ?? null);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setLoading(false);
}
}, [applyItem, selectedId, settings]);
useEffect(() => { void reload(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!selectedId) {
setRevisions([]);
setRenders([]);
return;
}
let active = true;
void Promise.all([
listTemplateRevisions(settings, selectedId),
listTemplateRenders(settings, selectedId)
]).then(([nextRevisions, nextRenders]) => {
if (!active) return;
setRevisions(nextRevisions);
setRenders(nextRenders);
}).catch((caught) => {
if (active) setError(errorMessage(caught));
});
return () => { active = false; };
}, [selectedId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const visibleItems = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return needle
? items.filter((item) => `${item.name} ${item.template_type} ${item.revision.usages.join(" ")}`.toLocaleLowerCase().includes(needle))
: items;
}, [items, search]);
const save = async () => {
if (!selected || !draft.name.trim() || !draft.usages.length) return false;
setBusy(true);
setError("");
try {
const updated = await updateTemplate(settings, selected, draft);
setSuccess(`Saved immutable revision ${updated.current_revision}.`);
await reload(updated.id);
return true;
} catch (caught) {
setError(errorMessage(caught));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => applyItem(selected),
title: "i18n:govoplan-templates.unsaved_title",
message: "i18n:govoplan-templates.unsaved_message"
});
const create = async (): Promise<boolean> => {
if (!createName.trim()) return false;
setBusy(true);
setError("");
try {
const created = await createTemplate(settings, {
...emptyPayload(createType),
name: createName.trim()
});
setCreateOpen(false);
setCreateName("");
setSuccess(`Created ${created.name}.`);
await reload(created.id);
return true;
} catch (caught) {
setError(errorMessage(caught));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty: Boolean(createOpen && createName.trim()),
onSave: create,
onDiscard: () => {
setCreateOpen(false);
setCreateName("");
setCreateType("form_letter");
},
title: "i18n:govoplan-templates.create_unsaved_title",
message: "i18n:govoplan-templates.create_unsaved_message"
});
const closeCreate = () => {
if (busy) return;
if (createName.trim()) requestDiscard(() => setCreateOpen(false));
else setCreateOpen(false);
};
const publish = async () => {
if (!selected || dirty) return;
setBusy(true);
try {
const updated = await publishTemplate(settings, selected);
setPublishOpen(false);
setSuccess(`Published revision ${updated.current_revision}.`);
await reload(updated.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!selected) return;
setBusy(true);
try {
await deleteTemplate(settings, selected);
setDeleteOpen(false);
setSuccess(`Deleted ${selected.name}.`);
await reload();
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const runRender = async (final: boolean) => {
if (!selected || dirty) return;
setBusy(true);
setError("");
try {
const sample = parseSample(sampleText);
const fields = flattenFieldTypes(sample);
const nextCompatibility = await checkTemplateCompatibility(settings, selected, usage, fields, outputFormat);
setCompatibility(nextCompatibility);
if (!nextCompatibility.compatible) return;
const nextRender = await renderTemplate(settings, selected, {
usage,
outputFormat,
items: [sample],
final,
persistToFiles
});
setRender(nextRender);
if (final) setFinalRenderOpen(false);
setRenders(await listTemplateRenders(settings, selected.id));
setSuccess(`${final ? "Final" : "Preview"} output rendered with ${nextRender.item_count} item(s).`);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
contextActions={<strong>Template library</strong>}
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
/>
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Template library"
contentLabel="Template workspace"
contentClassName="templates-workspace"
primary={<>
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
<SelectionList variant="navigation" label="Templates">
{visibleItems.map((item) => (
<SelectionListItem
key={item.id}
selected={item.id === selectedId}
onClick={() => {
if (item.id === selectedId) return;
requestDiscard(() => {
setSelectedId(item.id);
applyItem(item);
});
}}
>
<SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
<StatusBadge status={item.status} label={item.status} />
</SelectionListItem>
))}
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
</SelectionList>
</>}
>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state={busy ? "saving" : dirty ? "dirty" : "clean"}
className="templates-workspace-toolbar"
contextActions={<span className="templates-current-title">
<strong>{selected?.name ?? "Select a template"}</strong>
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
</span>}
helpAction={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}
primaryActions={<>
<Button helpContextId="templates.action.publish" helpModuleId="templates" disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
<SegmentedControl
value={view}
onChange={setView}
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
ariaLabel="Template workspace"
/>
</>}
destructiveActions={<IconButton label="Delete template" helpContextId="templates.action.delete" helpModuleId="templates" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />}
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
saveAction={{
label: <><Save size={16} /> Save revision</>,
disabled: !selected || readOnly || busy,
disabledReason: busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined,
onClick: () => void save()
}}
/>
<div className="templates-alerts">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
{selected && readOnly && <ActionBlockerHint
tone="info"
reason={{
summary: "Template is read-only",
details: canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason,
requiredAction: TEMPLATES_I18N.permissionAction,
actor: TEMPLATES_I18N.permissionActor,
target: TEMPLATES_I18N.permissionDestination
}}
labels={{ requiredAction: TEMPLATES_I18N.requiredAction, actor: TEMPLATES_I18N.actor, target: TEMPLATES_I18N.destination }}
documentation={TEMPLATES_DOCUMENTATION}
/>}
</div>
<LoadingFrame loading={loading} label="Loading templates">
<div className="templates-content">
{!selected ? <StatePanel size="fill" title="Templates" description="Create or select a reusable template." /> : view === "definition" ? <>
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
</> : <>
<PreviewPanel
item={selected}
sampleText={sampleText}
usage={usage}
outputFormat={outputFormat}
persistToFiles={persistToFiles}
compatibility={compatibility}
render={render}
disabled={busy || dirty || !canRender}
disabledReason={busy ? TEMPLATES_I18N.busy : dirty ? TEMPLATES_I18N.saveBeforeAction : !canRender ? TEMPLATES_I18N.renderReason : undefined}
onSampleText={setSampleText}
onUsage={setUsage}
onOutputFormat={setOutputFormat}
onPersistToFiles={setPersistToFiles}
onRender={(final) => final ? setFinalRenderOpen(true) : void runRender(false)}
onDownload={() => render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))}
/>
<RenderHistory renders={renders} settings={settings} onError={setError} />
</>}
</div>
</LoadingFrame>
</WorkspaceLayout>
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
<DialogSection>
<FormGrid columns={2} collapseAt="standard">
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
</FormGrid>
</DialogSection>
</Dialog>
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
</WorkspaceFrame>
);
}
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
return <ContentSection className="templates-history">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></ActionToolbar>
<div className="templates-history-list">
{revisions.map((revision) => <div key={revision.id}>
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
<span className="templates-history-badges">
{revision.id === currentRevisionId && <StatusBadge status="current" label="Current" />}
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
</span>
</div>)}
{!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
</div>
</ContentSection>;
}
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
return <ContentSection className="templates-history">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></ActionToolbar>
<div className="templates-history-list">
{renders.map((item) => <div key={item.render_id}>
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
</div>)}
{!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
</div>
</ContentSection>;
}
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
const update = <K extends keyof TemplatePayload>(key: K, value: TemplatePayload[K]) => onChange({ ...draft, [key]: value });
const scopeOptions = [
{ value: "tenant:", label: "Tenant" },
{ value: `user:${auth.user.account_id}`, label: "Only me" },
...auth.groups.map((group) => ({ value: `group:${group.id}`, label: `Group: ${group.name}` }))
];
const scopeValue = `${draft.scope_type}:${draft.scope_id ?? ""}`;
const layout = draft.layout;
return (
<div className="templates-definition">
<div className="templates-definition-fields">
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.name} onChange={(event) => update("name", event.target.value)} /></FormField>
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select disabled={disabled} value={draft.template_type} onChange={(event) => update("template_type", event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
<FormField label="Locale" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.locale} onChange={(event) => update("locale", event.target.value)} /></FormField>
<FormField label="Visibility" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select disabled={disabled} value={scopeValue} onChange={(event) => { const [scopeType, scopeId] = event.target.value.split(":", 2); onChange({ ...draft, scope_type: scopeType as TemplatePayload["scope_type"], scope_id: scopeId || null }); }}>{scopeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></FormField>
<FormField label="Usages" help="Comma-separated capability contexts, for example campaign.postal or addresses.labels." documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.usages.join(", ")} onChange={(event) => update("usages", splitValues(event.target.value))} /></FormField>
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
</div>
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></ActionToolbar>
<div className="templates-fields-table">
{draft.required_fields.map((field, index) => (
<div className="templates-field-row" key={`${index}:${field.path}`}>
<input disabled={disabled} value={field.path} placeholder="recipient.address" aria-label="Field path" onChange={(event) => updateField(draft, index, { path: event.target.value }, onChange)} />
<select disabled={disabled} value={field.value_type} aria-label="Field type" onChange={(event) => updateField(draft, index, { value_type: event.target.value as TemplateFieldType }, onChange)}>{FIELD_TYPES.map((value) => <option key={value} value={value}>{value}</option>)}</select>
<input disabled={disabled} value={field.label ?? ""} placeholder="Label" aria-label="Field label" onChange={(event) => updateField(draft, index, { label: event.target.value || null }, onChange)} />
<ToggleSwitch checked={field.required} label="Required" disabled={disabled} onChange={(checked) => updateField(draft, index, { required: checked }, onChange)} />
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
</div>
))}
{!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
</div>
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
<div className="templates-layout-fields">
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
{draft.template_type === "label_sheet" && <>
<FormField label="Columns"><input type="number" min="1" max="12" disabled={disabled} value={Number(layout.columns ?? 3)} onChange={(event) => update("layout", { ...layout, columns: Number(event.target.value) })} /></FormField>
<FormField label="Rows"><input type="number" min="1" max="30" disabled={disabled} value={Number(layout.rows ?? 8)} onChange={(event) => update("layout", { ...layout, rows: Number(event.target.value) })} /></FormField>
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
</>}
</div>
</ContentSection>
<ContentSection className="templates-body-section">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></ActionToolbar>
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
</ContentSection>
</div>
);
}
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, disabledReason, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: {
item: TemplateDefinition;
sampleText: string;
usage: string;
outputFormat: "html" | "text";
persistToFiles: boolean;
compatibility: TemplateCompatibility | null;
render: TemplateRender | null;
disabled: boolean;
disabledReason?: string;
onSampleText: (value: string) => void;
onUsage: (value: string) => void;
onOutputFormat: (value: "html" | "text") => void;
onPersistToFiles: (value: boolean) => void;
onRender: (final: boolean) => void;
onDownload: () => void;
}) {
return (
<div className="templates-preview">
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></ActionToolbar>
<div className="templates-preview-controls">
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
<ToggleSwitch checked={persistToFiles} label="Store in Files when available" onChange={onPersistToFiles} />
</div>
<textarea className="templates-sample" value={sampleText} onChange={(event) => onSampleText(event.target.value)} spellCheck={false} aria-label="Sample item JSON" />
<div className="templates-preview-actions">
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
</div>
</ContentSection>
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
{compatibility.compatible
? "The selected usage, output profile, and supplied fields are compatible."
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
</DismissibleAlert>}
{render && <ContentSection className="templates-render-result">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></ActionToolbar>
<dl>
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
<div><dt>Output</dt><dd>{shortHash(render.output_sha256)}</dd></div>
<div><dt>Renderer</dt><dd>{render.renderer_version}</dd></div>
<div><dt>Items / pages</dt><dd>{render.item_count} / {render.page_count}</dd></div>
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
</dl>
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
</ContentSection>}
</div>
);
}
function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePayload {
return {
name: "",
description: null,
scope_type: "tenant",
scope_id: null,
template_type: templateType,
usages: templateType === "email"
? ["campaign.email", "campaign.content"]
: templateType === "content_fragment"
? ["campaign.content"]
: ["campaign.postal"],
locale: "en",
required_fields: [],
output_profiles: [],
content_text: null,
content_html: "<p>Hello {{name}},</p><p></p>",
layout: { page_size: pageSizeForType(templateType), margin_mm: 15 },
metadata: {}
};
}
function payloadFromItem(item: TemplateDefinition): TemplatePayload {
return {
name: item.name,
slug: item.slug,
description: item.description ?? null,
scope_type: item.scope_type,
scope_id: item.scope_id ?? null,
template_type: item.template_type,
usages: [...item.revision.usages],
locale: item.revision.locale,
required_fields: item.revision.required_fields.map((field) => ({ ...field })),
output_profiles: item.revision.output_profiles.map((profile) => ({ ...profile, capabilities: [...profile.capabilities], page: { ...profile.page } })),
content_text: item.revision.content_text ?? null,
content_html: item.revision.content_html ?? null,
layout: { ...item.revision.layout },
metadata: { ...item.revision.metadata }
};
}
function emptyField(): TemplateFieldRequirement {
return { path: "", value_type: "string", label: null, required: true, description: null };
}
function updateField(draft: TemplatePayload, index: number, patch: Partial<TemplateFieldRequirement>, onChange: (draft: TemplatePayload) => void) {
onChange({ ...draft, required_fields: draft.required_fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) });
}
function pageSizeForType(type: TemplateType): string { return type === "envelope" ? "DL" : "A4"; }
function typeLabel(type: TemplateType): string { return TEMPLATE_TYPES.find((item) => item.value === type)?.label ?? type; }
function splitValues(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim().toLocaleLowerCase()).filter(Boolean))]; }
function draftKey(value: TemplatePayload): string { return JSON.stringify(value); }
function shortHash(value: string): string { return value.slice(0, 12); }
function parseSample(value: string): Record<string, unknown> {
const parsed: unknown = JSON.parse(value);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("Sample input must be one JSON object.");
return parsed as Record<string, unknown>;
}
function flattenFieldTypes(value: Record<string, unknown>, prefix = "", result: Record<string, string> = {}): Record<string, string> {
for (const [key, item] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(item)) result[path] = "array";
else if (item !== null && typeof item === "object") {
result[path] = "object";
flattenFieldTypes(item as Record<string, unknown>, path, result);
} else if (typeof item === "number") result[path] = Number.isInteger(item) ? "integer" : "number";
else result[path] = typeof item;
}
return result;
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: unknown };
return typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail ?? parsed);
} catch { return error.message; }
}
return error instanceof Error ? error.message : "The template operation failed.";
}
@@ -0,0 +1,35 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const TEMPLATES_DOCUMENTATION = {
topicId: "templates.library",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const TEMPLATE_FIELDS_DOCUMENTATION = {
topicId: "templates.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const TEMPLATE_OUTPUT_DOCUMENTATION = {
topicId: "templates.printable-output",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const TEMPLATES_I18N = {
loading: "i18n:govoplan-templates.loading_reason",
busy: "i18n:govoplan-templates.busy_reason",
writeReason: "i18n:govoplan-templates.write_permission_reason",
publishReason: "i18n:govoplan-templates.publish_permission_reason",
renderReason: "i18n:govoplan-templates.render_permission_reason",
readOnlyReason: "i18n:govoplan-templates.read_only_reason",
noSelection: "i18n:govoplan-templates.no_selection_reason",
noChanges: "i18n:govoplan-templates.no_changes_reason",
incomplete: "i18n:govoplan-templates.incomplete_reason",
saveBeforeAction: "i18n:govoplan-templates.save_before_action_reason",
requiredAction: "i18n:govoplan-templates.required_action",
actor: "i18n:govoplan-templates.actor",
destination: "i18n:govoplan-templates.destination",
permissionAction: "i18n:govoplan-templates.permission_action",
permissionActor: "i18n:govoplan-templates.permission_actor",
permissionDestination: "i18n:govoplan-templates.permission_destination"
} as const;
+147
View File
@@ -0,0 +1,147 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-templates.templates": "Templates",
"i18n:govoplan-templates.library": "Template library",
"i18n:govoplan-templates.editor": "Template editor",
"i18n:govoplan-templates.preview": "Template preview and output",
"i18n:govoplan-templates.loading_reason": "Templates are still loading.",
"i18n:govoplan-templates.busy_reason": "Another Template action is still running.",
"i18n:govoplan-templates.write_permission_reason": "Your account may not create or revise Templates.",
"i18n:govoplan-templates.publish_permission_reason": "Your account may not publish Template revisions.",
"i18n:govoplan-templates.render_permission_reason": "Your account may not render Template output.",
"i18n:govoplan-templates.read_only_reason": "This Template is inherited or otherwise read-only in the current scope.",
"i18n:govoplan-templates.no_selection_reason": "Select or create a Template first.",
"i18n:govoplan-templates.no_changes_reason": "There are no definition changes to save.",
"i18n:govoplan-templates.incomplete_reason": "Complete the required name and usage fields first.",
"i18n:govoplan-templates.save_before_action_reason": "Save the current revision before publishing or rendering it.",
"i18n:govoplan-templates.required_action": "Required action",
"i18n:govoplan-templates.actor": "Responsible actor",
"i18n:govoplan-templates.destination": "Where to continue",
"i18n:govoplan-templates.permission_action": "Ask for Template management permission or select a writable Template.",
"i18n:govoplan-templates.permission_actor": "A tenant administrator or the owner of the governing scope",
"i18n:govoplan-templates.permission_destination": "Access and Template scope administration",
"i18n:govoplan-templates.unsaved_title": "Unsaved Template revision",
"i18n:govoplan-templates.unsaved_message": "Save or discard this Template revision before leaving the editor.",
"i18n:govoplan-templates.create_unsaved_title": "Uncreated Template",
"i18n:govoplan-templates.create_unsaved_message": "Create the Template or discard its name before leaving this dialog.",
"i18n:govoplan-templates.publish_title": "Publish Template revision",
"i18n:govoplan-templates.publish_message": "Publish this immutable revision? Consumers may use it for final output until another revision is published.",
"i18n:govoplan-templates.render_title": "Render final output",
"i18n:govoplan-templates.render_message": "Render final output from this published revision and the current sample input? The render hashes and output evidence will be retained.",
"Template is read-only": "Template is read-only",
"Template library": "Template library",
"Search templates": "Search templates",
"No matching templates.": "No matching templates.",
"Select a template": "Select a template",
"Discard and reload": "Discard and reload",
"Save revision": "Save revision",
"Publish": "Publish",
"Delete template": "Delete template",
"Definition": "Definition",
"Preview": "Preview",
"Loading templates": "Loading templates",
"Create or select a reusable template.": "Create or select a reusable template.",
"Add template": "Add template",
"Name": "Name",
"Type": "Type",
"Locale": "Locale",
"Visibility": "Visibility",
"Usages": "Usages",
"Description": "Description",
"Required data contract": "Required data contract",
"Add field": "Add field",
"No required fields. Tokens still resolve from supplied parameters and items.": "No required fields. Tokens still resolve from supplied parameters and items.",
"Page and media": "Page and media",
"Page size": "Page size",
"Margin (mm)": "Margin (mm)",
"Columns": "Columns",
"Rows": "Rows",
"Gap (mm)": "Gap (mm)",
"Template body": "Template body",
"Revision history": "Revision history",
"Output history": "Output history",
"Validated sample input": "Validated sample input",
"Usage": "Usage",
"Output": "Output",
"Store in Files when available": "Store in Files when available",
"Validate and preview": "Validate and preview",
"Render final output": "Render final output",
"Render evidence": "Render evidence",
"Download": "Download",
"Delete template?": "Delete template?"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-templates.templates": "Vorlagen",
"i18n:govoplan-templates.library": "Vorlagenbibliothek",
"i18n:govoplan-templates.editor": "Vorlageneditor",
"i18n:govoplan-templates.preview": "Vorlagenvorschau und Ausgabe",
"i18n:govoplan-templates.loading_reason": "Vorlagen werden noch geladen.",
"i18n:govoplan-templates.busy_reason": "Eine andere Vorlagenaktion läuft noch.",
"i18n:govoplan-templates.write_permission_reason": "Ihr Konto darf Vorlagen nicht erstellen oder überarbeiten.",
"i18n:govoplan-templates.publish_permission_reason": "Ihr Konto darf Vorlagenrevisionen nicht veröffentlichen.",
"i18n:govoplan-templates.render_permission_reason": "Ihr Konto darf keine Vorlagenausgabe erzeugen.",
"i18n:govoplan-templates.read_only_reason": "Diese Vorlage ist im aktuellen Bereich geerbt oder anderweitig schreibgeschützt.",
"i18n:govoplan-templates.no_selection_reason": "Wählen oder erstellen Sie zuerst eine Vorlage.",
"i18n:govoplan-templates.no_changes_reason": "Es gibt keine Definitionsänderungen zu speichern.",
"i18n:govoplan-templates.incomplete_reason": "Füllen Sie zuerst Name und Verwendungszweck aus.",
"i18n:govoplan-templates.save_before_action_reason": "Speichern Sie die aktuelle Revision, bevor Sie sie veröffentlichen oder ausgeben.",
"i18n:govoplan-templates.required_action": "Erforderliche Aktion",
"i18n:govoplan-templates.actor": "Verantwortliche Stelle",
"i18n:govoplan-templates.destination": "Fortsetzung",
"i18n:govoplan-templates.permission_action": "Fordern Sie die Vorlagenberechtigung an oder wählen Sie eine beschreibbare Vorlage.",
"i18n:govoplan-templates.permission_actor": "Mandantenadministration oder Eigentümer des maßgeblichen Bereichs",
"i18n:govoplan-templates.permission_destination": "Zugriffs- und Vorlagenbereichsverwaltung",
"i18n:govoplan-templates.unsaved_title": "Ungespeicherte Vorlagenrevision",
"i18n:govoplan-templates.unsaved_message": "Speichern oder verwerfen Sie diese Vorlagenrevision, bevor Sie den Editor verlassen.",
"i18n:govoplan-templates.create_unsaved_title": "Nicht erstellte Vorlage",
"i18n:govoplan-templates.create_unsaved_message": "Erstellen Sie die Vorlage oder verwerfen Sie ihren Namen, bevor Sie diesen Dialog verlassen.",
"i18n:govoplan-templates.publish_title": "Vorlagenrevision veröffentlichen",
"i18n:govoplan-templates.publish_message": "Diese unveränderliche Revision veröffentlichen? Verbraucher dürfen sie für endgültige Ausgaben nutzen, bis eine andere Revision veröffentlicht wird.",
"i18n:govoplan-templates.render_title": "Endgültige Ausgabe erzeugen",
"i18n:govoplan-templates.render_message": "Endgültige Ausgabe aus dieser veröffentlichten Revision und den aktuellen Beispieldaten erzeugen? Ausgabe-Hashes und Nachweise werden aufbewahrt.",
"Template is read-only": "Vorlage ist schreibgeschützt",
"Template library": "Vorlagenbibliothek",
"Search templates": "Vorlagen suchen",
"No matching templates.": "Keine passenden Vorlagen.",
"Select a template": "Vorlage auswählen",
"Discard and reload": "Verwerfen und neu laden",
"Save revision": "Revision speichern",
"Publish": "Veröffentlichen",
"Delete template": "Vorlage löschen",
"Definition": "Definition",
"Preview": "Vorschau",
"Loading templates": "Vorlagen werden geladen",
"Create or select a reusable template.": "Erstellen oder wählen Sie eine wiederverwendbare Vorlage.",
"Add template": "Vorlage hinzufügen",
"Name": "Name",
"Type": "Typ",
"Locale": "Gebietsschema",
"Visibility": "Sichtbarkeit",
"Usages": "Verwendungen",
"Description": "Beschreibung",
"Required data contract": "Erforderlicher Datenvertrag",
"Add field": "Feld hinzufügen",
"No required fields. Tokens still resolve from supplied parameters and items.": "Keine Pflichtfelder. Platzhalter werden weiterhin aus Parametern und Einträgen aufgelöst.",
"Page and media": "Seite und Medium",
"Page size": "Seitengröße",
"Margin (mm)": "Rand (mm)",
"Columns": "Spalten",
"Rows": "Zeilen",
"Gap (mm)": "Abstand (mm)",
"Template body": "Vorlageninhalt",
"Revision history": "Revisionsverlauf",
"Output history": "Ausgabeverlauf",
"Validated sample input": "Validierte Beispieldaten",
"Usage": "Verwendung",
"Output": "Ausgabe",
"Store in Files when available": "Wenn verfügbar in Dateien speichern",
"Validate and preview": "Validieren und Vorschau erzeugen",
"Render final output": "Endgültige Ausgabe erzeugen",
"Render evidence": "Ausgabenachweis",
"Download": "Herunterladen",
"Delete template?": "Vorlage löschen?"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+4
View File
@@ -0,0 +1,4 @@
export { default } from "./module";
export * from "./module";
export * from "./api/templates";
export { default as TemplatesPage } from "./features/templates/TemplatesPage";
+44
View File
@@ -0,0 +1,44 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/templates.css";
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const readScopes = [
"templates:template:read",
"templates:template:write",
"templates:template:publish",
"templates:template:render",
"templates:template:admin"
];
export const templatesModule: PlatformWebModule = {
id: "templates",
label: "i18n:govoplan-templates.templates",
version: "0.1.14",
optionalDependencies: ["files", "dist_lists", "campaigns", "audit"],
translations: generatedTranslations,
viewSurfaces: [
{ id: "templates.page", moduleId: "templates", kind: "route", label: "i18n:govoplan-templates.templates", order: 75 },
{ id: "templates.library", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.library", parentId: "templates.page", order: 10 },
{ id: "templates.editor", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.editor", parentId: "templates.page", order: 20 },
{ id: "templates.preview", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.preview", parentId: "templates.page", order: 30 }
],
navItems: [{
to: "/templates",
label: "i18n:govoplan-templates.templates",
iconName: "layout-template",
anyOf: readScopes,
order: 75
}],
routes: [{
path: "/templates",
anyOf: readScopes,
order: 75,
surfaceId: "templates.page",
render: ({ settings, auth }) => createElement(TemplatesPage, { settings, auth })
}]
};
export default templatesModule;
+57
View File
@@ -0,0 +1,57 @@
.templates-workspace-toolbar { min-width: 0; }
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.templates-current-title { min-width: 0; flex: 1 1 auto; }
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
.templates-alerts:empty { display: none; }
.templates-alerts .alert { margin: 10px 0 0; }
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
.templates-definition-fields input, .templates-definition-fields select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
.templates-section-heading small { color: var(--muted); font-weight: 400; }
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-fields-table { overflow: auto; padding: 8px; }
.templates-field-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(110px, .6fr) minmax(160px, 1fr) auto 34px; align-items: center; gap: 8px; min-width: 710px; padding: 4px 0; }
.templates-field-row input, .templates-field-row select { width: 100%; }
.templates-layout-fields { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; padding: 12px; }
.templates-body-section .wysiwyg-editor { margin: 12px; }
.templates-preview { max-width: 1100px; margin: 0 auto; }
.templates-preview-controls { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(250px, 1.2fr) auto; align-items: end; gap: 14px; padding: 12px; }
.templates-sample { display: block; width: calc(100% - 24px); min-height: 260px; margin: 0 12px; padding: 10px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.templates-preview-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px; }
.templates-preview-actions .btn, .templates-render-result .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-render-result dl { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 10px; margin: 0; padding: 12px; }
.templates-render-result dl div { padding: 9px; border: var(--border-line); background: var(--panel-soft); }
.templates-render-result dt { color: var(--muted); font-size: 11px; text-transform: uppercase; }
.templates-render-result dd { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.templates-render-result > p { margin: 0; padding: 0 12px 12px; color: var(--muted); }
.templates-history-list { max-height: 260px; overflow: auto; padding: 6px; }
.templates-history-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 50px; padding: 7px 9px; border-bottom: var(--border-line); }
.templates-history-list > div:last-child { border-bottom: 0; }
.templates-history-list strong, .templates-history-list small { display: block; }
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-history-list .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
@media (max-width: 1100px) {
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-preview-controls { grid-template-columns: 1fr; align-items: stretch; }
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
.templates-toolbar-actions { flex-wrap: wrap; }
.templates-content { height: auto; overflow: visible; }
.templates-definition-fields, .templates-render-result dl { grid-template-columns: 1fr; }
}
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModuleLoaders: Array<{
packageName: string;
load: () => Promise<{ default: PlatformWebModule }>;
}>;
export default installedWebModuleLoaders;
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}