diff --git a/README.md b/README.md
index 4ba8c7c..db33287 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,31 @@
contracts for GovOPlaN. It is intentionally separate from reporting, DMS/files,
mail delivery, forms runtime, and workflow state.
-This repository is currently a tag-only scaffold. It should gain package
-metadata and module manifests only after the first backend or WebUI slice is
-designed.
+The first operational slice provides:
+
+- a scoped, versioned library for labels, label sheets, envelopes, serial and
+ form letters, list layouts, email, and generic templates;
+- explicit usages, locales, required-field contracts, output profiles, and
+ compatibility diagnostics;
+- safe deterministic HTML/text rendering from frozen caller-owned snapshots;
+- immutable template/input/output hashes, renderer version, item/page counts,
+ diagnostics, and idempotent final-output evidence;
+- optional managed artifact persistence through the Core Files contract, with
+ a 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).
diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md
new file mode 100644
index 0000000..b2d0c04
--- /dev/null
+++ b/docs/ADMIN_GUIDE.md
@@ -0,0 +1,32 @@
+# 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.
+
+## 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.
diff --git a/docs/TEMPLATE_BOUNDARY.md b/docs/TEMPLATE_BOUNDARY.md
index 68dfef1..82cce14 100644
--- a/docs/TEMPLATE_BOUNDARY.md
+++ b/docs/TEMPLATE_BOUNDARY.md
@@ -1,113 +1,55 @@
# Template Module Boundary
-`govoplan-templates` owns reusable renderable templates, not the data selection
-or persistence semantics around the generated output.
+`govoplan-templates` owns reusable render definitions and immutable render
+evidence. Callers own data selection, approval, delivery, and lifecycle state.
-The core boundary decision register is in
-`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
+## Owned Concepts
-## Ownership
+- scoped template definitions and immutable revisions;
+- template type, usage, locale, required-field contracts, output profiles, and
+ page/media hints;
+- safe HTML/text bodies and deterministic token substitution;
+- draft preview and published final rendering;
+- template, input, renderer, and output hashes plus item/page counts and
+ diagnostics; and
+- bounded fallback payloads when no artifact store is available.
-Templates owns:
+The implemented printable types are `label`, `label_sheet`, `envelope`,
+`serial_letter`, `form_letter`, and `list_layout`. `email` and `generic` use the
+same contract while preserving their explicit usage.
-- template definitions for letters, decisions, permits, emails, forms, reports,
- certificates, notices, and workflow messages
-- template versions, draft/published lifecycle, localization, and merge-field
- declarations
-- render profiles such as output format, page/layout hints, fallback language,
- and safe preview mode
-- render-context schema declarations so callers know which fields are required
-- reusable template fragments inside configuration packages
-- rendering capability contracts exposed to mail, campaign, reporting, forms,
- workflow, cases, DMS, and files
+## Consumer Boundary
-## Boundaries
+Templates never imports Addresses, Distribution Lists, Campaign, Files, Mail,
+Reporting, Forms, or Workflow internals. Consumers discover
+`templates.catalog` and `templates.renderer` through Core and submit plain
+provider-neutral DTOs. The supplied `input_snapshot` records a stable source
+reference; Templates does not fetch or silently refresh that source.
-Templates does not own:
+Files optionally implements `files.artifact_store`. A final render can request
+managed persistence through that contract. If Files is absent, incompatible,
+or unauthorized, the result carries a warning and remains available through a
+5 MiB bounded Templates download. Managed output is not duplicated in the
+Templates payload column.
-- report data selection, aggregation, dashboards, scheduled exports, or BI
- semantics; those belong to `govoplan-reporting`
-- document lifecycle, collaborative editing, locks, approvals, legal hold, or
- records management; those belong to `govoplan-dms` and `govoplan-records`
-- file/blob storage and file permissions; those belong to `govoplan-files`
-- mail sending, mailbox behavior, and mail profile policy; those belong to
- `govoplan-mail`
-- form submissions, drafts, receipts, and public submission state; those belong
- to `govoplan-forms-runtime` when implemented
-- workflow transitions, tasks, and case lifecycle
+## Safety And Determinism
-## Initial Template Types
+- backend sanitization removes scripts, styles, active embeds, unsafe links,
+ event handlers, and undeclared attributes;
+- substituted values are HTML escaped;
+- output is limited to 5,000 items and 5 MiB;
+- final output requires a published revision and idempotency key;
+- reusing an idempotency key with changed input is rejected;
+- every render pins the immutable definition hash and canonical input hash;
+- final artifacts contain hashes and references, not credentials or plaintext
+ secrets in provenance; and
+- browser/OS printing from deterministic HTML is the baseline. PDF conversion
+ and managed printer delivery belong to future connector adapters.
-- `letter`
-- `decision_document`
-- `permit`
-- `email`
-- `form`
-- `report`
-- `certificate`
-- `notice`
-- `workflow_message`
+## Recovery
-Template types can share a render engine but should keep type-specific metadata
-explicit, especially when retention, signature, accessibility, or delivery
-rules differ.
-
-## Render Context Contract
-
-Candidate render request:
-
-```json
-{
- "template_id": "permit-decision",
- "template_version_id": "v1",
- "template_type": "permit",
- "locale": "de-DE",
- "output_format": "pdf",
- "context": {
- "case_id": "case-1",
- "recipient": {"display_name": "Example Person"},
- "decision": {"approved": true}
- },
- "trace": {"correlation_id": "request-1"}
-}
-```
-
-Candidate render response:
-
-```json
-{
- "render_id": "render-1",
- "template_id": "permit-decision",
- "template_version_id": "v1",
- "output_format": "pdf",
- "artifact": {
- "content_type": "application/pdf",
- "storage_ref": "files://generated/render-1.pdf",
- "checksum": "sha256:..."
- },
- "warnings": []
-}
-```
-
-Generated artifact storage can be delegated to files/DMS through capabilities.
-Templates should not import those modules directly.
-
-## Candidate Capabilities
-
-- `templates.catalog`
-- `templates.renderer`
-- `templates.preview`
-- `templates.schema`
-- `templates.packageFragments`
-
-Consumers should request these through core-mediated capability lookup. The
-template module should not import consumer modules.
-
-## First Implementation Slice
-
-1. Define manifest metadata, permissions, and capability names.
-2. Add template definition/version DTOs.
-3. Add render-context schema validation for one safe text/PDF preview path.
-4. Add package fragment format for reusable templates.
-5. Add tests that mail/campaign/reporting/forms can detect template capability
- presence without importing template internals.
+Template definitions, revisions, render evidence, and bounded output are in the
+shared database and therefore follow platform backup and restore. Managed Files
+artifacts follow Files recovery. Retiring the module is destructive only after
+the installer captures a database snapshot; consumers retain pinned hashes and
+must diagnose the now-unavailable provider.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
new file mode 100644
index 0000000..c195315
--- /dev/null
+++ b/docs/USER_GUIDE.md
@@ -0,0 +1,20 @@
+# 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.
+
+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.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ff3a004
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[build-system]
+requires = ["setuptools>=69", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "govoplan-templates"
+version = "0.1.14"
+description = "GovOPlaN typed template library and deterministic printable rendering."
+readme = "README.md"
+requires-python = ">=3.12"
+authors = [{ name = "GovOPlaN" }]
+dependencies = [
+ "govoplan-core>=0.1.14",
+]
+
+[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"
diff --git a/src/govoplan_templates/__init__.py b/src/govoplan_templates/__init__.py
new file mode 100644
index 0000000..077a55e
--- /dev/null
+++ b/src/govoplan_templates/__init__.py
@@ -0,0 +1,3 @@
+"""GovOPlaN Templates module."""
+
+__version__ = "0.1.14"
diff --git a/src/govoplan_templates/backend/__init__.py b/src/govoplan_templates/backend/__init__.py
new file mode 100644
index 0000000..46a277e
--- /dev/null
+++ b/src/govoplan_templates/backend/__init__.py
@@ -0,0 +1 @@
+"""Backend implementation for GovOPlaN Templates."""
diff --git a/src/govoplan_templates/backend/capabilities.py b/src/govoplan_templates/backend/capabilities.py
new file mode 100644
index 0000000..ab13a7a
--- /dev/null
+++ b/src/govoplan_templates/backend/capabilities.py
@@ -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",
+]
diff --git a/src/govoplan_templates/backend/db/__init__.py b/src/govoplan_templates/backend/db/__init__.py
new file mode 100644
index 0000000..d4f8716
--- /dev/null
+++ b/src/govoplan_templates/backend/db/__init__.py
@@ -0,0 +1,7 @@
+from govoplan_templates.backend.db.models import (
+ TemplateDefinition,
+ TemplateRender,
+ TemplateRevision,
+)
+
+__all__ = ["TemplateDefinition", "TemplateRender", "TemplateRevision"]
diff --git a/src/govoplan_templates/backend/db/models.py b/src/govoplan_templates/backend/db/models.py
new file mode 100644
index 0000000..490930d
--- /dev/null
+++ b/src/govoplan_templates/backend/db/models.py
@@ -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")
diff --git a/src/govoplan_templates/backend/manifest.py b/src/govoplan_templates/backend/manifest.py
new file mode 100644
index 0000000..e59a388
--- /dev/null
+++ b/src/govoplan_templates/backend/manifest.py
@@ -0,0 +1,247 @@
+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.14"
+
+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},
+ ),
+ 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},
+ ),
+)
+
+
+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
diff --git a/src/govoplan_templates/backend/migrations/__init__.py b/src/govoplan_templates/backend/migrations/__init__.py
new file mode 100644
index 0000000..2403c81
--- /dev/null
+++ b/src/govoplan_templates/backend/migrations/__init__.py
@@ -0,0 +1 @@
+"""Alembic migrations for Templates."""
diff --git a/src/govoplan_templates/backend/migrations/versions/__init__.py b/src/govoplan_templates/backend/migrations/versions/__init__.py
new file mode 100644
index 0000000..7b28d12
--- /dev/null
+++ b/src/govoplan_templates/backend/migrations/versions/__init__.py
@@ -0,0 +1 @@
+"""Templates migration revisions."""
diff --git a/src/govoplan_templates/backend/migrations/versions/a3f7c9d2e1b4_templates_baseline.py b/src/govoplan_templates/backend/migrations/versions/a3f7c9d2e1b4_templates_baseline.py
new file mode 100644
index 0000000..92e690a
--- /dev/null
+++ b/src/govoplan_templates/backend/migrations/versions/a3f7c9d2e1b4_templates_baseline.py
@@ -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")
diff --git a/src/govoplan_templates/backend/rendering.py b/src/govoplan_templates/backend/rendering.py
new file mode 100644
index 0000000..dc1f325
--- /dev/null
+++ b/src/govoplan_templates/backend/rendering.py
@@ -0,0 +1,726 @@
+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 (
+ 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)
+ 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 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"
{escape(revision.content_text or '')}"
+ 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'' for item in rendered[start:start + per_page])
+ pages.append(f'{labels}')
+ 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'{"".join(rendered)}'
+ type_css = ".template-list>*{break-inside:avoid;}"
+ else:
+ body = "".join(f'{item}' for item in rendered)
+ type_css = ""
+ return (
+ ""
+ f"{escape(definition.name)}{body}"
+ )
+
+
+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",
+]
diff --git a/src/govoplan_templates/backend/router.py b/src/govoplan_templates/backend/router.py
new file mode 100644
index 0000000..e651e30
--- /dev/null
+++ b/src/govoplan_templates/backend/router.py
@@ -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"]
diff --git a/src/govoplan_templates/backend/schemas.py b/src/govoplan_templates/backend/schemas.py
new file mode 100644
index 0000000..2bd26b4
--- /dev/null
+++ b/src/govoplan_templates/backend/schemas.py
@@ -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
diff --git a/src/govoplan_templates/backend/service.py b/src/govoplan_templates/backend/service.py
new file mode 100644
index 0000000..32ee793
--- /dev/null
+++ b/src/govoplan_templates/backend/service.py
@@ -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",
+]
diff --git a/src/govoplan_templates/py.typed b/src/govoplan_templates/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/govoplan_templates/py.typed
@@ -0,0 +1 @@
+
diff --git a/tests/test_templates.py b/tests/test_templates.py
new file mode 100644
index 0000000..42d4a73
--- /dev/null
+++ b/tests/test_templates.py
@@ -0,0 +1,281 @@
+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,
+ 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 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") -> ApiPrincipal:
+ return ApiPrincipal(
+ principal=PrincipalRef(
+ account_id="account-1",
+ membership_id="membership-1",
+ tenant_id=tenant_id,
+ identity_id="identity-1",
+ scopes=frozenset(
+ {
+ "templates:template:read",
+ "templates:template:write",
+ "templates:template:publish",
+ "templates:template:render",
+ "templates:template:admin",
+ "files:file:upload",
+ }
+ ),
+ ),
+ account=object(),
+ user=type("User", (), {"id": "user-1"})(),
+ )
+
+
+def payload(
+ name: str = "Postal letter",
+ *,
+ template_type: str = "serial_letter",
+ body: str = "Hello {{name}}
{{postal.address}}
",
+) -> 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="Dear {{name}}
{{postal.address}}
").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_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("Safe
", sanitize_template_html("Safe
"))
+ self.assertEqual("Unsafe", sanitize_template_html('Unsafe'))
+ 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()
diff --git a/webui/package.json b/webui/package.json
new file mode 100644
index 0000000..55ce5c2
--- /dev/null
+++ b/webui/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@govoplan/templates-webui",
+ "version": "0.1.14",
+ "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.14",
+ "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
+ }
+ }
+}
diff --git a/webui/src/api/templates.ts b/webui/src/api/templates.ts
new file mode 100644
index 0000000..b7b4f9c
--- /dev/null
+++ b/webui/src/api/templates.ts
@@ -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;
+};
+
+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;
+ metadata: Record;
+ 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;
+ 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;
+ metadata: Record;
+};
+
+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>;
+};
+
+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;
+};
+
+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>;
+ artifact?: TemplateArtifact | null;
+ generated_at?: string | null;
+};
+
+export async function listTemplates(settings: ApiSettings): Promise {
+ const result = await apiFetch<{ items: TemplateDefinition[] }>(
+ settings,
+ apiPath("/api/v1/templates", { limit: 500 })
+ );
+ return result.items;
+}
+
+export function listTemplateRevisions(
+ settings: ApiSettings,
+ templateId: string
+): Promise {
+ return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(templateId)}/revisions`);
+}
+
+export async function listTemplateRenders(
+ settings: ApiSettings,
+ templateId: string
+): Promise {
+ 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 {
+ return apiFetch(settings, "/api/v1/templates", {
+ method: "POST",
+ body: JSON.stringify(payload)
+ });
+}
+
+export function updateTemplate(
+ settings: ApiSettings,
+ item: TemplateDefinition,
+ payload: TemplatePayload
+): Promise {
+ 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 {
+ 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 {
+ 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,
+ outputFormat: "html" | "text"
+): Promise {
+ 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>;
+ final: boolean;
+ persistToFiles: boolean;
+ }
+): Promise {
+ 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 {
+ 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);
+}
diff --git a/webui/src/features/templates/TemplatesPage.tsx b/webui/src/features/templates/TemplatesPage.tsx
new file mode 100644
index 0000000..45e749c
--- /dev/null
+++ b/webui/src/features/templates/TemplatesPage.tsx
@@ -0,0 +1,566 @@
+import {
+ Download,
+ Eye,
+ FileCheck2,
+ Plus,
+ RefreshCw,
+ Save,
+ Send,
+ Trash2,
+ X
+} from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ApiError,
+ Button,
+ ConfirmDialog,
+ Dialog,
+ DismissibleAlert,
+ FormField,
+ IconButton,
+ LoadingFrame,
+ SegmentedControl,
+ StatusBadge,
+ ToggleSwitch,
+ formatDateTime,
+ hasScope,
+ 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";
+
+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([]);
+ const [selectedId, setSelectedId] = useState("");
+ const [draft, setDraft] = useState(emptyPayload());
+ const [savedKey, setSavedKey] = useState("");
+ const [view, setView] = useState("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("form_letter");
+ const [deleteOpen, setDeleteOpen] = 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(null);
+ const [render, setRender] = useState(null);
+ const [revisions, setRevisions] = useState([]);
+ const [renders, setRenders] = useState([]);
+
+ 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) });
+
+ const create = async () => {
+ if (!createName.trim()) return;
+ 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);
+ } catch (caught) {
+ setError(errorMessage(caught));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const publish = async () => {
+ if (!selected || dirty) return;
+ setBusy(true);
+ try {
+ const updated = await publishTemplate(settings, selected);
+ 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);
+ 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 (
+
+
+
+
+
+
+
+
+ {error && {error}}
+ {success && {success}}
+
+
+
+
+ {!selected ?
Create or select a reusable template.
: view === "definition" ? <>
+
+
+ > : <>
+
render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))}
+ />
+
+ >}
+
+
+
+
+
+
+ setDeleteOpen(false)} onConfirm={() => void remove()} />
+
+ );
+}
+
+function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
+ return
+ Revision history{revisions.length} immutable revision(s)
+
+ {revisions.map((revision) =>
+ Revision {revision.revision}{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}
+
+ {revision.id === currentRevisionId && }
+ {revision.id === publishedRevisionId && }
+
+
)}
+ {!revisions.length &&
No revision evidence is available.
}
+
+ ;
+}
+
+function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
+ return
+ Output history{renders.length} recent render(s)
+
+ {renders.map((item) =>
+ {item.filename}{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}
+
+
)}
+ {!renders.length &&
No output has been rendered for this template.
}
+
+ ;
+}
+
+function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
+ const update = (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 (
+
+
+ update("name", event.target.value)} />
+
+ update("locale", event.target.value)} />
+
+ update("usages", splitValues(event.target.value))} />
+ update("description", event.target.value || null)} />
+
+
+
+ Required data contract
+
+ {draft.required_fields.map((field, index) => (
+
+ updateField(draft, index, { path: event.target.value }, onChange)} />
+
+ updateField(draft, index, { label: event.target.value || null }, onChange)} />
+ updateField(draft, index, { required: checked }, onChange)} />
+ } variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
+
+ ))}
+ {!draft.required_fields.length &&
No required fields. Tokens still resolve from supplied parameters and items.
}
+
+
+
+
+ Page and media
+
+
+ update("layout", { ...layout, margin_mm: Number(event.target.value) })} />
+ {draft.template_type === "label_sheet" && <>
+ update("layout", { ...layout, columns: Number(event.target.value) })} />
+ update("layout", { ...layout, rows: Number(event.target.value) })} />
+ update("layout", { ...layout, gap_mm: Number(event.target.value) })} />
+ >}
+
+
+
+
+ Template bodyUse tokens such as {"{{name}}"} or {"{{recipient.address}}"}.
+ update("content_html", value || null)} minHeight={300} />
+
+
+ );
+}
+
+function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, 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;
+ onSampleText: (value: string) => void;
+ onUsage: (value: string) => void;
+ onOutputFormat: (value: "html" | "text") => void;
+ onPersistToFiles: (value: boolean) => void;
+ onRender: (final: boolean) => void;
+ onDownload: () => void;
+}) {
+ return (
+
+
+ Validated sample inputPreview and final output use the same pinned revision and canonical input.
+
+
+
+
+
+
+
+ {compatibility &&
+ {compatibility.compatible
+ ? "The selected usage, output profile, and supplied fields are compatible."
+ : compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
+ }
+
+ {render &&
+ Render evidence
+
+ - Revision
- {render.revision} · {shortHash(render.template_hash)}
+ - Input
- {shortHash(render.input_hash)}
+ - Output
- {shortHash(render.output_sha256)}
+ - Renderer
- {render.renderer_version}
+ - Items / pages
- {render.item_count} / {render.page_count}
+ - Generated
- {render.generated_at ? formatDateTime(render.generated_at) : "Now"}
+
+ {render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes
+ }
+
+ );
+}
+
+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: "Hello {{name}},
",
+ 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, 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 {
+ 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;
+}
+
+function flattenFieldTypes(value: Record, prefix = "", result: Record = {}): Record {
+ 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, 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.";
+}
diff --git a/webui/src/index.ts b/webui/src/index.ts
new file mode 100644
index 0000000..92938c0
--- /dev/null
+++ b/webui/src/index.ts
@@ -0,0 +1,4 @@
+export { default } from "./module";
+export * from "./module";
+export * from "./api/templates";
+export { default as TemplatesPage } from "./features/templates/TemplatesPage";
diff --git a/webui/src/module.ts b/webui/src/module.ts
new file mode 100644
index 0000000..c2bd8dc
--- /dev/null
+++ b/webui/src/module.ts
@@ -0,0 +1,35 @@
+import { createElement, lazy } from "react";
+import type { PlatformWebModule } from "@govoplan/core-webui";
+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: "Templates",
+ version: "0.1.14",
+ optionalDependencies: ["files", "dist_lists", "campaigns", "audit"],
+ navItems: [{
+ to: "/templates",
+ label: "Templates",
+ iconName: "layout-template",
+ anyOf: readScopes,
+ order: 75
+ }],
+ routes: [{
+ path: "/templates",
+ anyOf: readScopes,
+ order: 75,
+ render: ({ settings, auth }) => createElement(TemplatesPage, { settings, auth })
+ }]
+};
+
+export default templatesModule;
diff --git a/webui/src/styles/templates.css b/webui/src/styles/templates.css
new file mode 100644
index 0000000..9de7de9
--- /dev/null
+++ b/webui/src/styles/templates.css
@@ -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; }
+}
diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts
new file mode 100644
index 0000000..31982c0
--- /dev/null
+++ b/webui/src/vite-env.d.ts
@@ -0,0 +1,21 @@
+///
+
+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;
+}
diff --git a/webui/tsconfig.json b/webui/tsconfig.json
new file mode 100644
index 0000000..30cfa96
--- /dev/null
+++ b/webui/tsconfig.json
@@ -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"]
+}