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'
{item}
' 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"") + + 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 ( +
+
+ + +
+
+ + {selected?.name ?? "Select a template"} + {selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""} + +
+ } onClick={() => void reload(selectedId)} /> + + + } variant="danger" disabled={!selected || readOnly} onClick={() => setDeleteOpen(true)} /> + +
+
+ +
+ {error && {error}} + {success && {success}} +
+ + +
+ {!selected ?

Create or select a reusable template.

: view === "definition" ? <> + + + : <> + render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))} + /> + + } +
+
+
+
+ + setCreateOpen(false)} footer={<>}> +
+ setCreateName(event.target.value)} /> + +
+
+ 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.
+
+ + + +
+