Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8a70f864 | ||
|
|
378ad9ec83 | ||
|
|
7c661384d9 | ||
|
|
95a443f359 | ||
|
|
0dcaa90abd | ||
|
|
21f3a6cd38 | ||
|
|
79f70a2492 | ||
|
|
72fafa23c7 | ||
|
|
3551c48e14 | ||
|
|
b65b905b6e | ||
|
|
142c3a26f1 | ||
|
|
403f0d01ed | ||
|
|
49f0f48e74 |
@@ -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
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
*.db
|
||||
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
@@ -0,0 +1,38 @@
|
||||
# GovOPlaN Templates
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-templates` owns reusable renderable template definitions and render
|
||||
contracts for GovOPlaN. It is intentionally separate from reporting, DMS/files,
|
||||
mail delivery, forms runtime, and workflow state.
|
||||
|
||||
The first operational slice provides:
|
||||
|
||||
- a scoped, versioned library for labels, label sheets, envelopes, serial and
|
||||
form letters, list layouts, email, and generic templates;
|
||||
- explicit usages, locales, required-field contracts, output profiles, and
|
||||
compatibility diagnostics;
|
||||
- safe deterministic HTML/text rendering from frozen caller-owned snapshots;
|
||||
- immutable template/input/output hashes, renderer version, item/page counts,
|
||||
diagnostics, and idempotent final-output evidence;
|
||||
- optional managed artifact persistence through the Core Files contract, with
|
||||
an actor-scoped bounded Templates download when Files is absent; and
|
||||
- a full-height library/editor/preview WebUI using the shared rich-text editor.
|
||||
|
||||
The module has no hard dependency on its consumers or on Files.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-templates
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:$PATH tsc -p webui/tsconfig.json --noEmit
|
||||
```
|
||||
|
||||
See [docs/TEMPLATE_BOUNDARY.md](docs/TEMPLATE_BOUNDARY.md) for the boundary
|
||||
decision.
|
||||
|
||||
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
||||
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Template Module Boundary
|
||||
|
||||
`govoplan-templates` owns reusable render definitions and immutable render
|
||||
evidence. Callers own data selection, approval, delivery, and lifecycle state.
|
||||
|
||||
## Owned Concepts
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Consumer Boundary
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Safety And Determinism
|
||||
|
||||
- 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.
|
||||
|
||||
## Recovery
|
||||
|
||||
Template definitions, revisions, render evidence, and bounded output are in the
|
||||
shared database and therefore follow platform backup and restore. Managed Files
|
||||
artifacts follow Files recovery. Bounded render payloads and history are visible
|
||||
only to their creator or a Templates administrator; consumers provide a
|
||||
resource-governed proxy when collaborators need access. Retiring the module is
|
||||
destructive only after the installer captures a database snapshot; consumers
|
||||
retain pinned hashes and must diagnose the now-unavailable provider.
|
||||
@@ -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