Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8a70f864 | ||
|
|
378ad9ec83 | ||
|
|
7c661384d9 | ||
|
|
95a443f359 | ||
|
|
0dcaa90abd | ||
|
|
21f3a6cd38 | ||
|
|
79f70a2492 | ||
|
|
72fafa23c7 | ||
|
|
3551c48e14 | ||
|
|
b65b905b6e |
@@ -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
|
||||||
@@ -8,9 +8,31 @@
|
|||||||
contracts for GovOPlaN. It is intentionally separate from reporting, DMS/files,
|
contracts for GovOPlaN. It is intentionally separate from reporting, DMS/files,
|
||||||
mail delivery, forms runtime, and workflow state.
|
mail delivery, forms runtime, and workflow state.
|
||||||
|
|
||||||
This repository is currently a tag-only scaffold. It should gain package
|
The first operational slice provides:
|
||||||
metadata and module manifests only after the first backend or WebUI slice is
|
|
||||||
designed.
|
- 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
|
See [docs/TEMPLATE_BOUNDARY.md](docs/TEMPLATE_BOUNDARY.md) for the boundary
|
||||||
decision.
|
decision.
|
||||||
|
|
||||||
|
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
||||||
|
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -1,113 +1,57 @@
|
|||||||
# Template Module Boundary
|
# Template Module Boundary
|
||||||
|
|
||||||
`govoplan-templates` owns reusable renderable templates, not the data selection
|
`govoplan-templates` owns reusable render definitions and immutable render
|
||||||
or persistence semantics around the generated output.
|
evidence. Callers own data selection, approval, delivery, and lifecycle state.
|
||||||
|
|
||||||
The core boundary decision register is in
|
## Owned Concepts
|
||||||
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
|
|
||||||
|
|
||||||
## 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,
|
## Consumer Boundary
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
## Safety And Determinism
|
||||||
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
|
|
||||||
|
|
||||||
## 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`
|
## Recovery
|
||||||
- `decision_document`
|
|
||||||
- `permit`
|
|
||||||
- `email`
|
|
||||||
- `form`
|
|
||||||
- `report`
|
|
||||||
- `certificate`
|
|
||||||
- `notice`
|
|
||||||
- `workflow_message`
|
|
||||||
|
|
||||||
Template types can share a render engine but should keep type-specific metadata
|
Template definitions, revisions, render evidence, and bounded output are in the
|
||||||
explicit, especially when retention, signature, accessibility, or delivery
|
shared database and therefore follow platform backup and restore. Managed Files
|
||||||
rules differ.
|
artifacts follow Files recovery. Bounded render payloads and history are visible
|
||||||
|
only to their creator or a Templates administrator; consumers provide a
|
||||||
## Render Context Contract
|
resource-governed proxy when collaborators need access. Retiring the module is
|
||||||
|
destructive only after the installer captures a database snapshot; consumers
|
||||||
Candidate render request:
|
retain pinned hashes and must diagnose the now-unavailable provider.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-templates"
|
||||||
|
version = "0.1.18"
|
||||||
|
description = "GovOPlaN typed template library and deterministic printable rendering."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Templates module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.18"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend implementation for GovOPlaN Templates."""
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.templates import (
|
||||||
|
TemplateCatalogProvider,
|
||||||
|
TemplateCompatibility,
|
||||||
|
TemplateRef,
|
||||||
|
)
|
||||||
|
from govoplan_templates.backend.rendering import SqlTemplateRenderer
|
||||||
|
from govoplan_templates.backend.service import (
|
||||||
|
READ_SCOPE,
|
||||||
|
compatibility,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
|
||||||
|
return SqlTemplateCatalog()
|
||||||
|
|
||||||
|
|
||||||
|
def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
|
||||||
|
return SqlTemplateRenderer(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
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 _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",
|
||||||
|
"catalog_capability",
|
||||||
|
"renderer_capability",
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from govoplan_templates.backend.db.models import (
|
||||||
|
TemplateDefinition,
|
||||||
|
TemplateRender,
|
||||||
|
TemplateRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["TemplateDefinition", "TemplateRender", "TemplateRevision"]
|
||||||
@@ -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,299 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 (
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.templates import (
|
||||||
|
CAPABILITY_TEMPLATE_CATALOG,
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "templates"
|
||||||
|
MODULE_NAME = "Templates"
|
||||||
|
MODULE_VERSION = "0.1.18"
|
||||||
|
|
||||||
|
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.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."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"templates.page",
|
||||||
|
"templates.library",
|
||||||
|
"templates.editor",
|
||||||
|
"templates.state.read-only",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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. 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"),
|
||||||
|
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"),
|
||||||
|
metadata={
|
||||||
|
"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 _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_RENDERER, version=MODULE_VERSION),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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_RENDERER: _renderer,
|
||||||
|
},
|
||||||
|
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=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",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Alembic migrations for Templates."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Templates migration revisions."""
|
||||||
+135
@@ -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")
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
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 "")
|
||||||
|
rendered = [
|
||||||
|
_substitute(body, _render_context(request.parameters, item, index), html=False)
|
||||||
|
for index, item in enumerate(items)
|
||||||
|
]
|
||||||
|
separator = "\n\n---\n\n" if revision.template_type != "list_layout" else "\n"
|
||||||
|
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 = [
|
||||||
|
_substitute(body, _render_context(request.parameters, item, index), html=True)
|
||||||
|
for index, item in enumerate(items)
|
||||||
|
]
|
||||||
|
page_count = _page_count(revision, len(items))
|
||||||
|
document = _html_document(definition, revision, rendered)
|
||||||
|
return document.encode("utf-8"), "text/html; charset=utf-8", page_count
|
||||||
|
|
||||||
|
|
||||||
|
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) -> str:
|
||||||
|
def replacement(match: re.Match[str]) -> str:
|
||||||
|
value, present = _resolve_path(context, match.group(1))
|
||||||
|
if not present or value is None:
|
||||||
|
return ""
|
||||||
|
rendered = _display_value(value)
|
||||||
|
return escape(rendered, quote=True) if html else rendered
|
||||||
|
|
||||||
|
return _TOKEN_PATTERN.sub(replacement, template)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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",
|
||||||
|
"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
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
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
|
||||||
|
),
|
||||||
|
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]]:
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_templates.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
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.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"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
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_RENDERER,
|
||||||
|
TemplateCompatibilityError,
|
||||||
|
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
|
||||||
|
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.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_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_RENDERER, manifest.capability_factories)
|
||||||
|
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/templates-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"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": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
apiDownload,
|
||||||
|
apiFetch,
|
||||||
|
apiPath,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type TemplateType =
|
||||||
|
| "label"
|
||||||
|
| "label_sheet"
|
||||||
|
| "envelope"
|
||||||
|
| "serial_letter"
|
||||||
|
| "form_letter"
|
||||||
|
| "list_layout"
|
||||||
|
| "email"
|
||||||
|
| "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,625 @@
|
|||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileCheck2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
IconButton,
|
||||||
|
LoadingFrame,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
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: "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 (
|
||||||
|
<main className="templates-page">
|
||||||
|
<div className="templates-shell">
|
||||||
|
<aside className="templates-sidebar">
|
||||||
|
<div className="templates-sidebar-toolbar">
|
||||||
|
<strong>Template library</strong>
|
||||||
|
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
|
||||||
|
</div>
|
||||||
|
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
||||||
|
<div className="templates-list">
|
||||||
|
{visibleItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={item.id === selectedId ? "is-selected" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.id === selectedId) return;
|
||||||
|
requestDiscard(() => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
applyItem(item);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
|
||||||
|
<StatusBadge status={item.status} label={item.status} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="templates-workspace">
|
||||||
|
<header className="templates-workspace-toolbar">
|
||||||
|
<span className="templates-current-title">
|
||||||
|
<strong>{selected?.name ?? "Select a template"}</strong>
|
||||||
|
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||||
|
</span>
|
||||||
|
<div className="templates-toolbar-actions">
|
||||||
|
<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />
|
||||||
|
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} disabled={loading || busy} disabledReason={loading ? TEMPLATES_I18N.loading : busy ? TEMPLATES_I18N.busy : undefined} onClick={() => requestDiscard(() => void reload(selectedId))} />
|
||||||
|
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : !dirty ? TEMPLATES_I18N.noChanges : undefined} onClick={() => void save()}><Save size={16} /> Save revision</Button>
|
||||||
|
<Button 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>
|
||||||
|
<IconButton label="Delete template" 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)} />
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={setView}
|
||||||
|
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
||||||
|
ariaLabel="Template workspace"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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 ? <p className="templates-empty">Create or select a reusable template.</p> : 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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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></>}>
|
||||||
|
<div className="templates-dialog-form">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</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()} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
|
||||||
|
return <section className="templates-section templates-history">
|
||||||
|
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
|
||||||
|
<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 && <p className="templates-inline-empty">No revision evidence is available.</p>}
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
||||||
|
return <section className="templates-section templates-history">
|
||||||
|
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
|
||||||
|
<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 && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section className="templates-section">
|
||||||
|
<div 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></div>
|
||||||
|
<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 && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="templates-section">
|
||||||
|
<div className="templates-section-heading"><strong>Page and media</strong></div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="templates-section templates-body-section">
|
||||||
|
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
|
||||||
|
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
|
||||||
|
</section>
|
||||||
|
</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">
|
||||||
|
<section className="templates-section">
|
||||||
|
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{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 && <section className="templates-section templates-render-result">
|
||||||
|
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
|
||||||
|
<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>
|
||||||
|
</section>}
|
||||||
|
</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.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;
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export * from "./api/templates";
|
||||||
|
export { default as TemplatesPage } from "./features/templates/TemplatesPage";
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.templates-page {
|
||||||
|
height: calc(100vh - 115px);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.templates-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
|
||||||
|
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
|
||||||
|
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
|
||||||
|
|
||||||
|
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
|
||||||
|
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
|
||||||
|
.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-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
|
||||||
|
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
|
||||||
|
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
|
||||||
|
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
|
||||||
|
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
|
||||||
|
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.templates-list 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-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||||
|
|
||||||
|
.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-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
|
||||||
|
|
||||||
|
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
|
||||||
|
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
|
||||||
|
.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; }
|
||||||
|
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
|
||||||
|
.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: 720px) {
|
||||||
|
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
|
||||||
|
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
|
||||||
|
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
|
||||||
|
.templates-workspace { 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-dialog-form, .templates-render-result dl { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
Vendored
+21
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user