Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9802eb4e | ||
|
|
1b1e3e0f92 | ||
|
|
1f61464fd6 | ||
|
|
19cbdd303b | ||
|
|
69416faca9 | ||
|
|
ee6fbc784f |
@@ -36,3 +36,21 @@ decision.
|
|||||||
|
|
||||||
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
||||||
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/templates-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/templates-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -36,3 +36,21 @@ Apply the module Alembic migration before startup. Monitor rejected renders for
|
|||||||
contract drift, output limits, missing Files permission, and reused idempotency
|
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
|
keys. HTML is designed for browser/OS printing; do not treat it as a signed PDF
|
||||||
or proof of physical printer delivery.
|
or proof of physical printer delivery.
|
||||||
|
|
||||||
|
Pure rendering runs in a disposable Core worker after authorization, revision
|
||||||
|
selection and idempotency lookup. Each worker has a 15-second wall limit,
|
||||||
|
10 CPU seconds, 512 MiB address space and no file output. Typed input transport
|
||||||
|
is limited to 32 MiB and result transport to 8 MiB, including serialization
|
||||||
|
overhead; nesting is limited to 64 levels and 1,000,000 value nodes. The existing
|
||||||
|
5 MiB final output and 5,000-item limits remain. Only data DTOs cross the
|
||||||
|
boundary; principals, SQL sessions, credentials and artifact writes stay in the
|
||||||
|
parent. Completed idempotent renders return before a worker is started.
|
||||||
|
|
||||||
|
`GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` in Core controls active isolated work per
|
||||||
|
API/worker process without queuing. Busy capacity produces a sanitized retryable
|
||||||
|
render error. CPU, memory, transport or time failures stop the entire render
|
||||||
|
before artifact persistence; reduce the workload before retrying. POSIX process
|
||||||
|
resource controls are required. Missing controls, cancellation or worker failure
|
||||||
|
fails closed with no in-process fallback. Budget aggregate memory across all
|
||||||
|
slots and API/worker replicas; monitor limit and overload errors separately
|
||||||
|
from template compatibility errors.
|
||||||
|
|||||||
@@ -21,3 +21,13 @@ Open **Templates** to create or select a reusable definition.
|
|||||||
Render evidence shows the exact revision and abbreviated template, input, and
|
Render evidence shows the exact revision and abbreviated template, input, and
|
||||||
output hashes. A consumer such as Campaign can submit many frozen recipients;
|
output hashes. A consumer such as Campaign can submit many frozen recipients;
|
||||||
the UI sample intentionally validates one representative item.
|
the UI sample intentionally validates one representative item.
|
||||||
|
|
||||||
|
Rendering is also limited to 15 seconds elapsed time, 10 CPU seconds, 512 MiB
|
||||||
|
process memory, 32 MiB serialized input and 8 MiB serialized result transport;
|
||||||
|
the final output remains limited to 5 MiB and 5,000 items. Deeply nested data
|
||||||
|
or more than 1,000,000 transported value nodes is rejected. A limit error creates
|
||||||
|
no partial output or render evidence: reduce the selected input or template
|
||||||
|
complexity and retry. If processing capacity is busy, retry later. Unavailable
|
||||||
|
worker controls or a worker failure require operator investigation. Existing
|
||||||
|
successful output with the same idempotency key is reused without rendering
|
||||||
|
again, provided its input and authorization still match.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/templates-webui",
|
||||||
|
"version": "0.1.22",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/templates.css": "./webui/src/styles/templates.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-templates"
|
name = "govoplan-templates"
|
||||||
version = "0.1.19"
|
version = "0.1.22"
|
||||||
description = "GovOPlaN typed template library and deterministic printable rendering."
|
description = "GovOPlaN typed template library and deterministic printable rendering."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Templates module."""
|
"""GovOPlaN Templates module."""
|
||||||
|
|
||||||
__version__ = "0.1.19"
|
__version__ = "0.1.22"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'templates.data-subject-requests': {'consequence_classes': {'exclude_template_payloads': 'Gibt '
|
||||||
|
'keinen '
|
||||||
|
'Template-Inhalt '
|
||||||
|
'zurück '
|
||||||
|
'oder '
|
||||||
|
'rendert '
|
||||||
|
'Inputs '
|
||||||
|
'und '
|
||||||
|
'Outputs.',
|
||||||
|
'export_template_attribution': 'Returns '
|
||||||
|
'minimiert '
|
||||||
|
'Autor, '
|
||||||
|
'Publikation '
|
||||||
|
'und '
|
||||||
|
'Render-Aktivität.'}},
|
||||||
|
'templates.reference.fields-and-consequences': {'consequence_classes': {'delete_template': 'Verhindert '
|
||||||
|
'zukünftige '
|
||||||
|
'Auswahl, '
|
||||||
|
'ohne '
|
||||||
|
'beibehaltene '
|
||||||
|
'Revisionen '
|
||||||
|
'neu '
|
||||||
|
'zu '
|
||||||
|
'schreiben '
|
||||||
|
'oder '
|
||||||
|
'Nachweise '
|
||||||
|
'zu '
|
||||||
|
'erbringen.',
|
||||||
|
'publish_revision': 'Macht '
|
||||||
|
'die '
|
||||||
|
'ausgewählte '
|
||||||
|
'unveränderliche '
|
||||||
|
'Revision '
|
||||||
|
'für '
|
||||||
|
'die '
|
||||||
|
'Endverbraucherproduktion '
|
||||||
|
'geeignet.',
|
||||||
|
'render_final': 'Erstellt '
|
||||||
|
'gespeicherte '
|
||||||
|
'Render-Nachweise '
|
||||||
|
'und kann '
|
||||||
|
'ein '
|
||||||
|
'Artefakt '
|
||||||
|
'durch '
|
||||||
|
'Dateien '
|
||||||
|
'beibehalten.',
|
||||||
|
'save_revision': 'Erstellt '
|
||||||
|
'eine '
|
||||||
|
'neue '
|
||||||
|
'unveränderliche '
|
||||||
|
'Template-Revision.'}}}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_templates.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
||||||
@@ -40,7 +43,7 @@ from govoplan_templates.backend.dsar_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "templates"
|
MODULE_ID = "templates"
|
||||||
MODULE_NAME = "Templates"
|
MODULE_NAME = "Templates"
|
||||||
MODULE_VERSION = "0.1.19"
|
MODULE_VERSION = "0.1.22"
|
||||||
|
|
||||||
READ_SCOPE = "templates:template:read"
|
READ_SCOPE = "templates:template:read"
|
||||||
WRITE_SCOPE = "templates:template:write"
|
WRITE_SCOPE = "templates:template:write"
|
||||||
@@ -99,6 +102,63 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="templates.render-worker-limits",
|
||||||
|
title="Rendering resource and capacity limits",
|
||||||
|
summary="Render in a disposable process and reject incomplete output before persistence.",
|
||||||
|
body=(
|
||||||
|
"Template substitution and document composition run in a fresh process with a 15-second wall limit, 10 CPU seconds, "
|
||||||
|
"512 MiB address space, no file output, 32 MiB typed input and 8 MiB typed result transport. The existing final output "
|
||||||
|
"limit remains 5 MiB and the item limit remains 5,000. Transport permits only explicit data types, at most 64 nesting "
|
||||||
|
"levels and 1,000,000 value nodes. Exceeding a limit fails the entire render before Files or render evidence is written; "
|
||||||
|
"reduce the input or template complexity and retry. Core GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY limits simultaneous work per "
|
||||||
|
"API/worker process without queuing; a busy error can be retried later. Missing POSIX resource controls, cancellation "
|
||||||
|
"and worker failures are explicit sanitized render errors, with no in-process fallback. Authorization, revision "
|
||||||
|
"selection, idempotency lookup, SQL sessions and artifact persistence remain in the parent. Existing completed "
|
||||||
|
"idempotent output is reused before starting a worker. Operators must budget aggregate memory across process slots."
|
||||||
|
),
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=30,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Ressourcen- und Kapazitätsgrenzen beim Rendern",
|
||||||
|
"summary": "In einem kurzlebigen Prozess rendern und unvollständige Ausgaben vor dem Speichern ablehnen.",
|
||||||
|
"body": (
|
||||||
|
"Platzhalterersetzung und Dokumentaufbau laufen in einem neuen Prozess mit höchstens 15 Sekunden Gesamtdauer, "
|
||||||
|
"10 CPU-Sekunden, 512 MiB Adressraum, keiner Dateiausgabe sowie 32 MiB typisierter Eingabe und 8 MiB Ergebnistransport. "
|
||||||
|
"Die endgültige Ausgabe bleibt auf 5 MiB und die Elementzahl auf 5.000 begrenzt. Der Transport erlaubt nur ausdrückliche "
|
||||||
|
"Datentypen, höchstens 64 Verschachtelungsebenen und 1.000.000 Wertknoten. Eine Grenzverletzung bricht den gesamten "
|
||||||
|
"Vorgang vor dem Speichern in Files oder als Render-Nachweis ab; reduzieren Sie Eingaben oder Vorlagenkomplexität "
|
||||||
|
"und versuchen Sie es erneut. Core GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY begrenzt gleichzeitige Arbeiten pro API-/Worker-Prozess "
|
||||||
|
"ohne Warteschlange; bei belegter Kapazität kann später erneut versucht werden. Fehlende POSIX-Ressourcenbegrenzungen, "
|
||||||
|
"Abbruch und Worker-Fehler sind ausdrückliche bereinigte Render-Fehler ohne Rückfall auf den Elternprozess. "
|
||||||
|
"Berechtigungsprüfung, Revisionsauswahl, Idempotenzprüfung, SQL-Sitzungen und Artefaktspeicherung bleiben im Elternprozess. "
|
||||||
|
"Bereits abgeschlossene idempotente Ausgaben werden vor einem Worker-Start wiederverwendet. Betreiber müssen den "
|
||||||
|
"Gesamtspeicher aller Prozessplätze berücksichtigen."
|
||||||
|
),
|
||||||
|
}},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="templates.workspace-layout",
|
||||||
|
title="Templates workspace actions",
|
||||||
|
summary="Find collection-wide commands in their consistent workspace position.",
|
||||||
|
body="Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Templates, not among operational action buttons. Field help remains beside its label. "
|
||||||
|
"Reload and Add template use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Template editing, saving, publishing, and rendering remain scoped to the selected template. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Vorlagen: Aktionen im Arbeitsbereich",
|
||||||
|
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
|
||||||
|
"body": "Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Vorlagen, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
|
||||||
|
"bleibt neben der Feldbezeichnung. "
|
||||||
|
"Neu laden und Vorlage hinzufügen stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Bearbeiten, Speichern, Veröffentlichen und Rendern bleiben der ausgewählten Vorlage zugeordnet. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="templates.library",
|
id="templates.library",
|
||||||
title="Template library",
|
title="Template library",
|
||||||
@@ -107,6 +167,7 @@ DOCUMENTATION = (
|
|||||||
"Templates are reusable, scoped definitions. Every edit creates an immutable revision. "
|
"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 "
|
"Publish the revision that consumers may use for final output. A compatibility check explains "
|
||||||
"missing fields, unsupported usages, and unavailable output formats before rendering. "
|
"missing fields, unsupported usages, and unavailable output formats before rendering. "
|
||||||
|
"The Add template dialog uses the shared responsive form layout: name, type, and footer actions remain inside the dialog on narrow screens without horizontal form scrolling."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -125,6 +186,7 @@ DOCUMENTATION = (
|
|||||||
"Vorlagen sind wiederverwendbare, bereichsgebundene Definitionen. Jede Bearbeitung erzeugt eine unveränderliche Revision. "
|
"Vorlagen sind wiederverwendbare, bereichsgebundene Definitionen. Jede Bearbeitung erzeugt eine unveränderliche Revision. "
|
||||||
"Veröffentlichen Sie die Revision, die Verbraucher für endgültige Ausgaben verwenden dürfen. Vor dem Rendern erläutert eine "
|
"Veröffentlichen Sie die Revision, die Verbraucher für endgültige Ausgaben verwenden dürfen. Vor dem Rendern erläutert eine "
|
||||||
"Kompatibilitätsprüfung fehlende Felder, nicht unterstützte Verwendungen und nicht verfügbare Ausgabeformate. "
|
"Kompatibilitätsprüfung fehlende Felder, nicht unterstützte Verwendungen und nicht verfügbare Ausgabeformate. "
|
||||||
|
"Der Dialog zum Hinzufügen einer Vorlage verwendet das gemeinsame responsive Formularlayout: Name, Typ und Fußzeilenaktionen bleiben auch auf schmalen Bildschirmen ohne horizontales Formularscrollen im Dialog."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -184,7 +246,9 @@ DOCUMENTATION = (
|
|||||||
"Preview output may use a draft revision. Final output requires a published revision and an "
|
"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, "
|
"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 "
|
"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."
|
"Templates provides a bounded download. The existing 5 MiB output ceiling is enforced while substituting tokens and composing items, "
|
||||||
|
"including UTF-8 bytes, escaping, separators, and the final HTML wrapper. Oversized expansion stops before the whole bundle is allocated "
|
||||||
|
"or any output is persisted; reduce the selected items or template content and retry. Browser printing is the supported baseline output path."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -198,7 +262,10 @@ DOCUMENTATION = (
|
|||||||
"Eine Vorschau darf eine Entwurfsrevision verwenden. Endgültige Ausgabe verlangt eine veröffentlichte Revision und einen "
|
"Eine Vorschau darf eine Entwurfsrevision verwenden. Endgültige Ausgabe verlangt eine veröffentlichte Revision und einen "
|
||||||
"Idempotenzschlüssel. Ergebnisse legen Vorlagenhash, Eingabehash, Renderer-Version, Element-/Seitenanzahl, Diagnosen und "
|
"Idempotenzschlüssel. Ergebnisse legen Vorlagenhash, Eingabehash, Renderer-Version, Element-/Seitenanzahl, Diagnosen und "
|
||||||
"Ausgabe-Digest fest. Files speichert Artefakte, wenn die Fähigkeit verfügbar und berechtigt ist; andernfalls stellt Templates "
|
"Ausgabe-Digest fest. Files speichert Artefakte, wenn die Fähigkeit verfügbar und berechtigt ist; andernfalls stellt Templates "
|
||||||
"einen begrenzten Download bereit. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
|
"einen begrenzten Download bereit. Die bestehende Ausgabegrenze von 5 MiB wird bereits beim Ersetzen von Platzhaltern und Zusammenstellen "
|
||||||
|
"der Elemente durchgesetzt, einschließlich UTF-8-Bytes, Maskierung, Trennzeichen und abschließender HTML-Hülle. Übermäßige Erweiterung "
|
||||||
|
"stoppt vor dem vollständigen Speicheraufbau oder Speichern einer Ausgabe; reduzieren Sie die ausgewählten Elemente oder den Vorlageninhalt "
|
||||||
|
"und versuchen Sie es erneut. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -545,5 +612,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ from govoplan_core.core.templates import (
|
|||||||
TemplateRenderRequest,
|
TemplateRenderRequest,
|
||||||
TemplateRenderResult,
|
TemplateRenderResult,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.security.bounded_process import (
|
||||||
|
ProcessBudgetError,
|
||||||
|
ProcessLimits,
|
||||||
|
run_bounded_operation,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
|
||||||
from govoplan_templates.backend.db.models import (
|
from govoplan_templates.backend.db.models import (
|
||||||
TemplateDefinition,
|
TemplateDefinition,
|
||||||
TemplateRender,
|
TemplateRender,
|
||||||
@@ -42,9 +48,36 @@ from govoplan_templates.backend.service import (
|
|||||||
RENDERER_VERSION = "templates-html-1"
|
RENDERER_VERSION = "templates-html-1"
|
||||||
MAX_OUTPUT_BYTES = 5 * 1024 * 1024
|
MAX_OUTPUT_BYTES = 5 * 1024 * 1024
|
||||||
MAX_ITEMS = 5_000
|
MAX_ITEMS = 5_000
|
||||||
|
RENDER_PROCESS_LIMITS = ProcessLimits(
|
||||||
|
wall_seconds=15,
|
||||||
|
cpu_seconds=10,
|
||||||
|
memory_bytes=512 * 1024 * 1024,
|
||||||
|
input_bytes=32 * 1024 * 1024,
|
||||||
|
output_bytes=8 * 1024 * 1024,
|
||||||
|
)
|
||||||
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
|
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
|
class _RenderDefinition:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
|
class _RenderRevision:
|
||||||
|
content_text: str | None
|
||||||
|
content_html: str | None
|
||||||
|
template_type: str
|
||||||
|
layout: dict[str, object]
|
||||||
|
output_profiles: list[dict[str, object]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
|
class _RenderRequest:
|
||||||
|
output_format: str
|
||||||
|
parameters: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
class SqlTemplateRenderer:
|
class SqlTemplateRenderer:
|
||||||
def __init__(self, registry: object | None = None) -> None:
|
def __init__(self, registry: object | None = None) -> None:
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
@@ -359,25 +392,114 @@ def _render_payload(
|
|||||||
*,
|
*,
|
||||||
request: TemplateRenderRequest,
|
request: TemplateRenderRequest,
|
||||||
items: Sequence[Mapping[str, object]],
|
items: Sequence[Mapping[str, object]],
|
||||||
|
) -> tuple[bytes, str, int]:
|
||||||
|
# Authoritative selection/idempotency precede this call. Transfer only the
|
||||||
|
# fields needed for pure rendering, never ORM objects, principals or Files.
|
||||||
|
try:
|
||||||
|
data = encode_worker_payload({
|
||||||
|
"definition": {"name": definition.name},
|
||||||
|
"revision": {
|
||||||
|
"content_text": revision.content_text,
|
||||||
|
"content_html": revision.content_html,
|
||||||
|
"template_type": revision.template_type,
|
||||||
|
"layout": dict(revision.layout),
|
||||||
|
"output_profiles": list(revision.output_profiles),
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"output_format": request.output_format,
|
||||||
|
"parameters": dict(request.parameters),
|
||||||
|
},
|
||||||
|
"items": tuple(dict(item) for item in items),
|
||||||
|
}, max_bytes=RENDER_PROCESS_LIMITS.input_bytes)
|
||||||
|
result = decode_worker_payload(
|
||||||
|
run_bounded_operation(_render_payload_worker, data, limits=RENDER_PROCESS_LIMITS),
|
||||||
|
max_bytes=RENDER_PROCESS_LIMITS.output_bytes,
|
||||||
|
)
|
||||||
|
except ProcessBudgetError as exc:
|
||||||
|
raise TemplateRenderError(f"Template rendering failed ({exc.code}): {exc}") from exc
|
||||||
|
except (TypeError, ValueError, RecursionError) as exc:
|
||||||
|
raise TemplateRenderError("Template input or output could not be safely transferred.") from exc
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise TemplateRenderError("Template worker returned an invalid result.")
|
||||||
|
if "render_error" in result:
|
||||||
|
raise TemplateRenderError(str(result["render_error"]))
|
||||||
|
payload, content_type, page_count = result.get("payload"), result.get("content_type"), result.get("page_count")
|
||||||
|
if not isinstance(payload, bytes) or not isinstance(content_type, str) or type(page_count) is not int:
|
||||||
|
raise TemplateRenderError("Template worker returned an invalid result.")
|
||||||
|
if len(payload) > MAX_OUTPUT_BYTES:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
return payload, content_type, page_count
|
||||||
|
|
||||||
|
|
||||||
|
def _render_payload_worker(payload: bytes) -> bytes:
|
||||||
|
data = decode_worker_payload(payload, max_bytes=RENDER_PROCESS_LIMITS.input_bytes)
|
||||||
|
definition = _RenderDefinition(**data["definition"])
|
||||||
|
revision = _RenderRevision(**data["revision"])
|
||||||
|
request = _RenderRequest(**data["request"])
|
||||||
|
try:
|
||||||
|
payload, content_type, page_count = _render_payload_content(
|
||||||
|
definition, revision, request=request, items=data["items"]
|
||||||
|
)
|
||||||
|
except TemplateRenderError as exc:
|
||||||
|
return encode_worker_payload({"render_error": str(exc)})
|
||||||
|
return encode_worker_payload(
|
||||||
|
{"payload": payload, "content_type": content_type, "page_count": page_count},
|
||||||
|
max_bytes=RENDER_PROCESS_LIMITS.output_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_payload_content(
|
||||||
|
definition: _RenderDefinition,
|
||||||
|
revision: _RenderRevision,
|
||||||
|
*,
|
||||||
|
request: _RenderRequest,
|
||||||
|
items: Sequence[Mapping[str, object]],
|
||||||
) -> tuple[bytes, str, int]:
|
) -> tuple[bytes, str, int]:
|
||||||
if request.output_format == "text":
|
if request.output_format == "text":
|
||||||
body = revision.content_text or _html_to_text(revision.content_html or "")
|
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"
|
separator = "\n\n---\n\n" if revision.template_type != "list_layout" else "\n"
|
||||||
|
rendered = _render_items(body, request.parameters, items, html=False, separator=separator)
|
||||||
payload = separator.join(rendered).encode("utf-8")
|
payload = separator.join(rendered).encode("utf-8")
|
||||||
return payload, "text/plain; charset=utf-8", _page_count(revision, len(items))
|
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>"
|
body = revision.content_html or f"<pre>{escape(revision.content_text or '')}</pre>"
|
||||||
rendered = [
|
rendered = _render_items(body, request.parameters, items, html=True)
|
||||||
_substitute(body, _render_context(request.parameters, item, index), html=True)
|
|
||||||
for index, item in enumerate(items)
|
|
||||||
]
|
|
||||||
page_count = _page_count(revision, len(items))
|
page_count = _page_count(revision, len(items))
|
||||||
document = _html_document(definition, revision, rendered)
|
document = _html_document(definition, revision, rendered)
|
||||||
return document.encode("utf-8"), "text/html; charset=utf-8", page_count
|
payload = document.encode("utf-8")
|
||||||
|
if len(payload) > MAX_OUTPUT_BYTES:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
return payload, "text/html; charset=utf-8", page_count
|
||||||
|
|
||||||
|
|
||||||
|
def _output_limit_exceeded() -> None:
|
||||||
|
raise TemplateRenderError(
|
||||||
|
f"Rendered output exceeds the {MAX_OUTPUT_BYTES} byte bounded-download limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_items(
|
||||||
|
body: str,
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
items: Sequence[Mapping[str, object]],
|
||||||
|
*,
|
||||||
|
html: bool,
|
||||||
|
separator: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
# Reject as soon as the same existing output budget is exhausted; never
|
||||||
|
# build thousands of oversized documents and only then measure the join.
|
||||||
|
remaining = MAX_OUTPUT_BYTES
|
||||||
|
separator_bytes = len(separator.encode("utf-8"))
|
||||||
|
rendered: list[str] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if index:
|
||||||
|
remaining -= separator_bytes
|
||||||
|
if remaining < 0:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
value = _substitute(body, _render_context(parameters, item, index), html=html, max_bytes=remaining)
|
||||||
|
remaining -= len(value.encode("utf-8"))
|
||||||
|
rendered.append(value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
def _html_document(
|
def _html_document(
|
||||||
@@ -552,15 +674,32 @@ def _render_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _substitute(template: str, context: Mapping[str, object], *, html: bool) -> str:
|
def _substitute(template: str, context: Mapping[str, object], *, html: bool, max_bytes: int | None = None) -> str:
|
||||||
def replacement(match: re.Match[str]) -> str:
|
remaining = MAX_OUTPUT_BYTES if max_bytes is None else max_bytes
|
||||||
value, present = _resolve_path(context, match.group(1))
|
pieces: list[str] = []
|
||||||
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 append(value: str) -> None:
|
||||||
|
nonlocal remaining
|
||||||
|
# Character count is a cheap lower bound before allocating UTF-8 bytes.
|
||||||
|
if len(value) > remaining:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
remaining -= len(value.encode("utf-8"))
|
||||||
|
if remaining < 0:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
pieces.append(value)
|
||||||
|
|
||||||
|
previous = 0
|
||||||
|
for match in _TOKEN_PATTERN.finditer(template):
|
||||||
|
append(template[previous:match.start()])
|
||||||
|
value, present = _resolve_path(context, match.group(1))
|
||||||
|
if present and value is not None:
|
||||||
|
rendered = _display_value(value)
|
||||||
|
if len(rendered) > remaining:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
append(escape(rendered, quote=True) if html else rendered)
|
||||||
|
previous = match.end()
|
||||||
|
append(template[previous:])
|
||||||
|
return "".join(pieces)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
|
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.core.templates import TemplateRenderError
|
||||||
|
from govoplan_templates.backend import rendering
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderLimitTests(unittest.TestCase):
|
||||||
|
def test_many_items_stop_before_rendering_the_entire_oversized_bundle(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_substitute", wraps=rendering._substitute) as substitute:
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
|
||||||
|
rendering._render_items("x" * 64, {}, [{}] * 5000, html=False)
|
||||||
|
self.assertEqual(3, substitute.call_count)
|
||||||
|
|
||||||
|
def test_repeated_token_expansion_stops_before_allocating_the_whole_row(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_display_value", wraps=rendering._display_value) as display:
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
|
||||||
|
rendering._substitute("{{value}}" * 5000, {"value": "x" * 64}, html=False)
|
||||||
|
self.assertEqual(3, display.call_count)
|
||||||
|
|
||||||
|
def test_utf8_byte_limit_is_not_a_character_limit(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 4):
|
||||||
|
self.assertEqual("üü", rendering._substitute("{{value}}", {"value": "üü"}, html=False))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._substitute("{{value}}", {"value": "üüü"}, html=False)
|
||||||
|
|
||||||
|
def test_html_escaping_counts_expanded_bytes_and_preserves_valid_output(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 9):
|
||||||
|
self.assertEqual("<&", rendering._substitute("{{value}}", {"value": "<&"}, html=True))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._substitute("{{value}}!", {"value": "<&"}, html=True)
|
||||||
|
|
||||||
|
def test_text_separator_is_part_of_the_same_budget(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 5):
|
||||||
|
self.assertEqual(["ab", "cd"], rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n"))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n\n")
|
||||||
|
|
||||||
|
def test_html_wrapper_is_also_subject_to_output_limit(self):
|
||||||
|
definition = SimpleNamespace(name="Example")
|
||||||
|
revision = SimpleNamespace(content_html="<p>Ada</p>", content_text=None, template_type="list_layout", layout={}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="html", parameters={})
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 32):
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||||
|
|
||||||
|
def test_fresh_worker_preserves_exact_text_bytes_and_page_count(self):
|
||||||
|
definition = SimpleNamespace(name="Example")
|
||||||
|
revision = SimpleNamespace(content_html=None, content_text="Hi {{name}}: {{flag}}", template_type="serial_letter", layout={}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="text", parameters={"flag": True})
|
||||||
|
with patch.object(rendering, "_render_payload_content", side_effect=AssertionError("parent renderer ran")):
|
||||||
|
result = rendering._render_payload(definition, revision, request=request, items=[{"name": "Äda"}, {"name": "Grace"}])
|
||||||
|
self.assertEqual(("Hi Äda: true\n\n---\n\nHi Grace: true".encode(), "text/plain; charset=utf-8", 2), result)
|
||||||
|
|
||||||
|
def test_real_worker_timeout_fails_without_parent_fallback(self):
|
||||||
|
definition = SimpleNamespace(name="Example")
|
||||||
|
revision = SimpleNamespace(content_html=None, content_text="Hello", template_type="serial_letter", layout={}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="text", parameters={})
|
||||||
|
limits = replace(rendering.RENDER_PROCESS_LIMITS, wall_seconds=0.001)
|
||||||
|
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits), patch.object(
|
||||||
|
rendering, "_render_payload_content", side_effect=AssertionError("parent renderer ran")
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "timeout"):
|
||||||
|
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||||
|
|
||||||
|
def test_worker_html_bytes_match_existing_composition(self):
|
||||||
|
definition = SimpleNamespace(name="Letters <archive>")
|
||||||
|
revision = SimpleNamespace(content_html="<p>{{name}}: {{settings}}</p>", content_text=None, template_type="label_sheet", layout={"page_size": "A5", "columns": 2, "rows": 2}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="html", parameters={"settings": {"b": True, "a": "ü"}})
|
||||||
|
items = [{"name": "<Ada>"}, {"name": "Grace & Co"}]
|
||||||
|
expected = rendering._render_payload_content(definition, revision, request=request, items=items)
|
||||||
|
actual = rendering._render_payload(definition, revision, request=request, items=items)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
def test_real_worker_output_transport_limit_is_enforced(self):
|
||||||
|
definition = SimpleNamespace(name="Example")
|
||||||
|
revision = SimpleNamespace(content_html=None, content_text="x" * 4096, template_type="serial_letter", layout={}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="text", parameters={})
|
||||||
|
limits = replace(rendering.RENDER_PROCESS_LIMITS, output_bytes=1024)
|
||||||
|
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits):
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "output_limit"):
|
||||||
|
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
@@ -19,6 +20,7 @@ from govoplan_core.core.templates import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.session import configure_database, reset_database
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_templates.backend import rendering
|
||||||
from govoplan_templates.backend.capabilities import (
|
from govoplan_templates.backend.capabilities import (
|
||||||
SqlTemplateCatalog,
|
SqlTemplateCatalog,
|
||||||
SqlTemplateContentLibrary,
|
SqlTemplateContentLibrary,
|
||||||
@@ -231,6 +233,7 @@ class TemplateServiceTests(unittest.TestCase):
|
|||||||
idempotency_key="campaign-1:postal-output-1",
|
idempotency_key="campaign-1:postal-output-1",
|
||||||
)
|
)
|
||||||
first = render_template(session, principal(), registry=_Registry(), request=request)
|
first = render_template(session, principal(), registry=_Registry(), request=request)
|
||||||
|
with patch.object(rendering, "run_bounded_operation", side_effect=AssertionError("idempotent render started a worker")):
|
||||||
second = render_template(session, principal(), registry=_Registry(), request=request)
|
second = render_template(session, principal(), registry=_Registry(), request=request)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -243,6 +246,33 @@ class TemplateServiceTests(unittest.TestCase):
|
|||||||
self.assertIn(b"Ada", first.payload)
|
self.assertIn(b"Ada", first.payload)
|
||||||
self.assertIn(b"Grace", first.payload)
|
self.assertIn(b"Grace", first.payload)
|
||||||
|
|
||||||
|
def test_real_worker_timeout_writes_neither_artifact_nor_render_evidence(self) -> None:
|
||||||
|
store = _ArtifactStore()
|
||||||
|
with self.database.session() as session:
|
||||||
|
item, _ = create_template(session, principal(), payload())
|
||||||
|
request = TemplateRenderRequest(
|
||||||
|
template_id=item.id,
|
||||||
|
usage="campaign.postal",
|
||||||
|
items=({"name": "Ada", "postal": {"address": "Street 1"}},),
|
||||||
|
persist_to_files=True,
|
||||||
|
)
|
||||||
|
limits = replace(rendering.RENDER_PROCESS_LIMITS, wall_seconds=0.001)
|
||||||
|
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits):
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "timeout"):
|
||||||
|
render_template(session, principal(), registry=_Registry(store), request=request)
|
||||||
|
self.assertIsNone(store.request)
|
||||||
|
self.assertEqual([], list_renders(session, principal()))
|
||||||
|
|
||||||
|
def test_incompatible_input_is_rejected_before_worker_admission(self) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
item, _ = create_template(session, principal(), payload())
|
||||||
|
request = TemplateRenderRequest(
|
||||||
|
template_id=item.id, usage="campaign.postal", items=({"name": "Missing address"},),
|
||||||
|
)
|
||||||
|
with patch.object(rendering, "run_bounded_operation", side_effect=AssertionError("incompatible render started a worker")):
|
||||||
|
with self.assertRaises(TemplateCompatibilityError):
|
||||||
|
render_template(session, principal(), registry=_Registry(), request=request)
|
||||||
|
|
||||||
def test_bounded_render_history_and_payload_are_owner_scoped(self) -> None:
|
def test_bounded_render_history_and_payload_are_owner_scoped(self) -> None:
|
||||||
owner = principal(admin=False)
|
owner = principal(admin=False)
|
||||||
other = principal(account_id="account-2", admin=False)
|
other = principal(account_id="account-2", admin=False)
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/templates-webui",
|
"name": "@govoplan/templates-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
"./styles/templates.css": "./src/styles/templates.css"
|
"./styles/templates.css": "./src/styles/templates.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:dialog-layout": "node scripts/test-dialog-layout.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const page = readFileSync(new URL("../src/features/templates/TemplatesPage.tsx", import.meta.url), "utf8");
|
||||||
|
const styles = readFileSync(new URL("../src/styles/templates.css", import.meta.url), "utf8");
|
||||||
|
assert.match(page, /<Dialog open=\{createOpen\}[^]*?<FormGrid columns=\{2\} collapseAt="standard">/);
|
||||||
|
assert.doesNotMatch(page, /templates-dialog-form/);
|
||||||
|
assert.doesNotMatch(styles, /templates-dialog-form/);
|
||||||
|
console.log("Templates dialog uses the shared shrinking form layout.");
|
||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { DialogSection, ActionToolbar,
|
import { FormGrid, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
ContentSection,
|
ContentSection,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogSection,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
|
TextWithHelp,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -302,6 +304,14 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
||||||
|
contextActions={<strong>Template library</strong>}
|
||||||
|
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
|
||||||
|
/>
|
||||||
<WorkspaceLayout
|
<WorkspaceLayout
|
||||||
variant="split"
|
variant="split"
|
||||||
primarySize="compact"
|
primarySize="compact"
|
||||||
@@ -312,14 +322,6 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
contentLabel="Template workspace"
|
contentLabel="Template workspace"
|
||||||
contentClassName="templates-workspace"
|
contentClassName="templates-workspace"
|
||||||
primary={<>
|
primary={<>
|
||||||
<WorkspaceActionBar
|
|
||||||
scope="collection-pane"
|
|
||||||
variant="collection"
|
|
||||||
refreshable
|
|
||||||
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
|
||||||
contextActions={<strong>Template library</strong>}
|
|
||||||
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
|
|
||||||
/>
|
|
||||||
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
|
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
|
||||||
<SelectionList variant="navigation" label="Templates">
|
<SelectionList variant="navigation" label="Templates">
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
@@ -348,12 +350,13 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
className="templates-workspace-toolbar"
|
className="templates-workspace-toolbar"
|
||||||
contextActions={<span className="templates-current-title">
|
contextActions={<span className="templates-current-title">
|
||||||
|
<TextWithHelp help={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}>
|
||||||
<strong>{selected?.name ?? "Select a template"}</strong>
|
<strong>{selected?.name ?? "Select a template"}</strong>
|
||||||
|
</TextWithHelp>
|
||||||
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||||
</span>}
|
</span>}
|
||||||
helpAction={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}
|
|
||||||
primaryActions={<>
|
primaryActions={<>
|
||||||
<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>
|
<Button helpContextId="templates.action.publish" helpModuleId="templates" disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={view}
|
value={view}
|
||||||
onChange={setView}
|
onChange={setView}
|
||||||
@@ -361,7 +364,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
ariaLabel="Template workspace"
|
ariaLabel="Template workspace"
|
||||||
/>
|
/>
|
||||||
</>}
|
</>}
|
||||||
destructiveActions={<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)} />}
|
destructiveActions={<IconButton label="Delete template" helpContextId="templates.action.delete" helpModuleId="templates" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />}
|
||||||
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
|
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
|
||||||
saveAction={{
|
saveAction={{
|
||||||
label: <><Save size={16} /> Save revision</>,
|
label: <><Save size={16} /> Save revision</>,
|
||||||
@@ -418,9 +421,11 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
|
|
||||||
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
|
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
|
||||||
<DialogSection className="templates-dialog-form">
|
<DialogSection>
|
||||||
|
<FormGrid columns={2} collapseAt="standard">
|
||||||
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
<FormField label="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>
|
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||||
|
</FormGrid>
|
||||||
</DialogSection>
|
</DialogSection>
|
||||||
</Dialog>
|
</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={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
|
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
|
||||||
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
|
.templates-definition-fields { 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 .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-definition-fields input, .templates-definition-fields select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
|
||||||
|
|
||||||
.templates-section-heading small { color: var(--muted); font-weight: 400; }
|
.templates-section-heading small { color: var(--muted); font-weight: 400; }
|
||||||
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
|
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
.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-list .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.templates-history-badges { display: 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: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -54,5 +53,5 @@
|
|||||||
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
|
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
|
||||||
.templates-toolbar-actions { flex-wrap: wrap; }
|
.templates-toolbar-actions { flex-wrap: wrap; }
|
||||||
.templates-content { height: auto; overflow: visible; }
|
.templates-content { height: auto; overflow: visible; }
|
||||||
.templates-definition-fields, .templates-dialog-form, .templates-render-result dl { grid-template-columns: 1fr; }
|
.templates-definition-fields, .templates-render-result dl { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user