Implement typed template library and rendering

This commit is contained in:
2026-08-02 12:38:38 +02:00
parent 142c3a26f1
commit b65b905b6e
28 changed files with 4385 additions and 104 deletions
+25 -3
View File
@@ -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).
+32
View File
@@ -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.
+43 -101
View File
@@ -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.
+20
View File
@@ -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.
+23
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Templates module."""
__version__ = "0.1.14"
@@ -0,0 +1 @@
"""Backend implementation for GovOPlaN Templates."""
@@ -0,0 +1,152 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.templates import (
TemplateCatalogProvider,
TemplateCompatibility,
TemplateRef,
)
from govoplan_templates.backend.rendering import SqlTemplateRenderer
from govoplan_templates.backend.service import (
READ_SCOPE,
compatibility,
get_template,
get_template_revision,
list_templates,
template_ref,
)
class SqlTemplateCatalog(TemplateCatalogProvider):
def list_templates(
self,
session: object,
principal: object,
*,
query: str = "",
usage: str | None = None,
template_type: str | None = None,
locale: str | None = None,
limit: int = 100,
) -> Sequence[TemplateRef]:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
rows = list_templates(
sql_session,
api_principal,
query=query,
usage=usage,
template_type=template_type,
locale=locale,
limit=limit,
)
return tuple(
template_ref(
row,
get_template_revision(sql_session, row, published_preferred=True),
read_only=_read_only(api_principal, row.scope_type, row.scope_id),
)
for row in rows
)
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
) -> TemplateRef | None:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
try:
row = get_template(sql_session, api_principal, template_id)
item_revision = get_template_revision(
sql_session,
row,
revision=revision,
published_preferred=revision is None,
)
except ValueError:
return None
return template_ref(
row,
item_revision,
read_only=_read_only(api_principal, row.scope_type, row.scope_id),
)
def check_compatibility(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
usage: str | None = None,
output_format: str | None = None,
available_fields: Mapping[str, str] | Sequence[str] = (),
) -> TemplateCompatibility:
sql_session, api_principal = _context(session, principal)
_require_read(api_principal)
row = get_template(sql_session, api_principal, template_id)
item_revision = get_template_revision(
sql_session,
row,
revision=revision,
published_preferred=revision is None,
)
return compatibility(
item_revision,
usage=usage,
output_format=output_format,
available_fields=available_fields,
)
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
return SqlTemplateCatalog()
def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
return SqlTemplateRenderer(context.registry)
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
if not isinstance(session, Session):
raise TypeError("Template catalogue access requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Template catalogue access requires an API principal.")
return session, principal
def _require_read(principal: ApiPrincipal) -> None:
if not any(
principal.has(scope)
for scope in (
READ_SCOPE,
"templates:template:write",
"templates:template:publish",
"templates:template:admin",
)
):
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.")
def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
if principal.has("templates:template:admin") or scope_type == "tenant":
return False
if scope_type == "user":
return scope_id != principal.account_id
return scope_id not in principal.group_ids
__all__ = [
"SqlTemplateCatalog",
"catalog_capability",
"renderer_capability",
]
@@ -0,0 +1,7 @@
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
__all__ = ["TemplateDefinition", "TemplateRender", "TemplateRevision"]
+163
View File
@@ -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")
+247
View File
@@ -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
@@ -0,0 +1 @@
"""Alembic migrations for Templates."""
@@ -0,0 +1 @@
"""Templates migration revisions."""
@@ -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")
+726
View File
@@ -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"<pre>{escape(revision.content_text or '')}</pre>"
rendered = [
_substitute(body, _render_context(request.parameters, item, index), html=True)
for index, item in enumerate(items)
]
page_count = _page_count(revision, len(items))
document = _html_document(definition, revision, rendered)
return document.encode("utf-8"), "text/html; charset=utf-8", page_count
def _html_document(
definition: TemplateDefinition,
revision: TemplateRevision,
rendered: Sequence[str],
) -> str:
page_size = _page_size(revision.layout.get("page_size") or _profile_page_size(revision))
margin = _millimetres(revision.layout.get("margin_mm"), 15.0, minimum=0, maximum=60)
template_type = revision.template_type
if template_type == "label_sheet":
columns = _integer(revision.layout.get("columns"), 3, minimum=1, maximum=12)
rows = _integer(revision.layout.get("rows"), 8, minimum=1, maximum=30)
gap = _millimetres(revision.layout.get("gap_mm"), 2.0, minimum=0, maximum=20)
per_page = columns * rows
pages = []
for start in range(0, len(rendered), per_page):
labels = "".join(f'<section class="template-label">{item}</section>' for item in rendered[start:start + per_page])
pages.append(f'<main class="template-page template-label-sheet">{labels}</main>')
body = "".join(pages)
type_css = (
f".template-label-sheet{{display:grid;grid-template-columns:repeat({columns},minmax(0,1fr));"
f"grid-template-rows:repeat({rows},minmax(0,1fr));gap:{gap}mm;}}"
".template-label{overflow:hidden;border:0.2mm solid #c9c9c9;padding:2mm;}"
)
elif template_type == "list_layout":
body = f'<main class="template-page template-list">{"".join(rendered)}</main>'
type_css = ".template-list>*{break-inside:avoid;}"
else:
body = "".join(f'<main class="template-page">{item}</main>' for item in rendered)
type_css = ""
return (
"<!doctype html><html><head><meta charset=\"utf-8\">"
f"<title>{escape(definition.name)}</title><style>"
f"@page{{size:{page_size};margin:{margin}mm;}}"
"*{box-sizing:border-box;}html,body{margin:0;padding:0;color:#171717;background:#fff;"
"font-family:Arial,Helvetica,sans-serif;font-size:10pt;line-height:1.35;}"
".template-page{break-after:page;min-height:1px;}"
".template-page:last-child{break-after:auto;}table{border-collapse:collapse;width:100%;}"
"th,td{padding:1.5mm;text-align:left;vertical-align:top;}"
f"{type_css}</style></head><body>{body}</body></html>"
)
def _persist_artifact(
registry: object | None,
session: Session,
principal: ApiPrincipal,
*,
request: TemplateRenderRequest,
definition: TemplateDefinition,
revision: TemplateRevision,
payload: bytes,
filename: str,
content_type: str,
input_hash: str,
output_sha256: str,
diagnostics: list[dict[str, object]],
) -> TemplateArtifactRef | None:
if not request.persist_to_files:
return None
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_ARTIFACT_STORE)
):
diagnostics.append(
{
"code": "template.files_unavailable",
"severity": "warning",
"message": "Files artifact storage is unavailable; using a bounded Templates download.",
}
)
return None
capability = registry.capability(CAPABILITY_FILES_ARTIFACT_STORE)
if not isinstance(capability, ManagedArtifactStore):
diagnostics.append(
{
"code": "template.files_contract_invalid",
"severity": "warning",
"message": "Files artifact storage has an incompatible contract; using a bounded Templates download.",
}
)
return None
try:
stored = capability.store_artifact(
session,
principal,
request=ManagedArtifactWriteRequest(
filename=filename,
payload=payload,
content_type=content_type,
folder="Generated/Templates",
description=f"Rendered from template {definition.name} revision {revision.revision}.",
idempotency_key=request.idempotency_key,
metadata={
"producer_module": "templates",
"template_id": definition.id,
"template_revision_id": revision.id,
"template_hash": revision.definition_hash,
"input_hash": input_hash,
"output_sha256": output_sha256,
},
),
)
except (PermissionError, RuntimeError, ValueError) as exc:
diagnostics.append(
{
"code": "template.files_store_failed",
"severity": "warning",
"message": "Managed Files persistence was not permitted or available; using a bounded Templates download.",
"error_type": type(exc).__name__,
}
)
return None
return TemplateArtifactRef(
kind="managed_file",
filename=stored.filename,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
sha256=stored.sha256,
file_asset_id=stored.file_asset_id,
file_version_id=stored.file_version_id,
download_path=f"/api/v1/files/{stored.file_asset_id}/download",
provenance=dict(stored.provenance),
)
def _idempotent_render(
session: Session,
principal: ApiPrincipal,
*,
request: TemplateRenderRequest,
revision: TemplateRevision,
input_hash: str,
) -> TemplateRender | None:
if not request.idempotency_key:
return None
existing = session.scalar(
select(TemplateRender).where(
TemplateRender.tenant_id == principal.tenant_id,
TemplateRender.idempotency_key == request.idempotency_key,
)
)
if existing is None:
return None
if (
existing.template_id != revision.template_id
or existing.revision_id != revision.id
or existing.input_hash != input_hash
or existing.output_format != request.output_format
or existing.mode != request.mode
):
raise TemplateRenderError(
"The render idempotency key was already used for different input."
)
return existing
def _render_context(
parameters: Mapping[str, object],
item: Mapping[str, object],
index: int,
) -> dict[str, object]:
return {
**dict(parameters),
**dict(item),
"parameters": dict(parameters),
"item": dict(item),
"recipient": dict(item),
"index": index + 1,
}
def _substitute(template: str, context: Mapping[str, object], *, html: bool) -> str:
def replacement(match: re.Match[str]) -> str:
value, present = _resolve_path(context, match.group(1))
if not present or value is None:
return ""
rendered = _display_value(value)
return escape(rendered, quote=True) if html else rendered
return _TOKEN_PATTERN.sub(replacement, template)
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
if path in context:
return context[path], True
current: object = context
for part in path.split("."):
if not isinstance(current, Mapping) or part not in current:
return None, False
current = current[part]
return current, True
def _available_field_types(
parameters: Mapping[str, object],
items: Sequence[Mapping[str, object]],
) -> dict[str, str]:
result: dict[str, str] = {}
for prefix, value in (("parameters", parameters),):
_flatten_types(value, prefix, result)
for item in items:
_flatten_types(item, "", result)
_flatten_types(item, "item", result)
_flatten_types(item, "recipient", result)
return result
def _flatten_types(value: object, prefix: str, result: dict[str, str]) -> None:
if isinstance(value, Mapping):
if prefix:
result.setdefault(prefix, "object")
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_flatten_types(item, path, result)
return
result.setdefault(prefix, _value_type(value))
def _value_type(value: object) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, float):
return "number"
if isinstance(value, Mapping):
return "object"
if isinstance(value, (list, tuple)):
return "array"
return "string"
def _value_matches_type(value: object, expected: str) -> bool:
actual = _value_type(value)
if expected == "number":
return actual in {"integer", "number"}
if expected in {"date", "datetime"}:
return isinstance(value, str) and bool(value.strip())
return actual == expected
def _display_value(value: object) -> str:
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _canonical_hash(value: object) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _page_count(revision: TemplateRevision, item_count: int) -> int:
if revision.template_type == "label_sheet":
columns = _integer(revision.layout.get("columns"), 3, minimum=1, maximum=12)
rows = _integer(revision.layout.get("rows"), 8, minimum=1, maximum=30)
return max(1, math.ceil(item_count / (columns * rows)))
if revision.template_type == "list_layout":
return 1
return max(1, item_count)
def _output_filename(
definition: TemplateDefinition,
revision: TemplateRevision,
output_format: str,
) -> str:
extension = "html" if output_format == "html" else "txt"
return f"{definition.slug}-r{revision.revision}.{extension}"
def _profile_page_size(revision: TemplateRevision) -> object:
for profile in revision.output_profiles:
page = profile.get("page") if isinstance(profile, dict) else None
if isinstance(page, dict) and page.get("size"):
return page["size"]
return "A4"
def _page_size(value: object) -> str:
normalized = str(value or "A4").upper()
return normalized if normalized in {"A3", "A4", "A5", "LETTER", "LEGAL", "DL"} else "A4"
def _integer(value: object, fallback: int, *, minimum: int, maximum: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
number = fallback
return max(minimum, min(maximum, number))
def _millimetres(value: object, fallback: float, *, minimum: float, maximum: float) -> str:
try:
number = float(value)
except (TypeError, ValueError):
number = fallback
number = max(minimum, min(maximum, number))
return f"{number:.2f}".rstrip("0").rstrip(".")
class _PlainTextExtractor(HTMLParser):
block_tags = {"br", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "tr"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
del attrs
if tag in self.block_tags:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
self.parts.append(data)
def _html_to_text(value: str) -> str:
parser = _PlainTextExtractor()
parser.feed(value)
parser.close()
return "\n".join(line.strip() for line in "".join(parser.parts).splitlines() if line.strip())
def _unique_diagnostics(items: Sequence[dict[str, object]]) -> list[dict[str, object]]:
seen: set[str] = set()
result: list[dict[str, object]] = []
for item in items:
key = json.dumps(item, sort_keys=True, default=str)
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def _template_visible(session: Session, principal: ApiPrincipal, template_id: str) -> bool:
try:
get_template(session, principal, template_id)
return True
except Exception:
return False
__all__ = [
"MAX_ITEMS",
"MAX_OUTPUT_BYTES",
"RENDERER_VERSION",
"SqlTemplateRenderer",
"get_render_for_principal",
"list_renders",
"render_result",
"render_template",
]
+529
View File
@@ -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"]
+252
View File
@@ -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
+674
View File
@@ -0,0 +1,674 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from html import escape
from html.parser import HTMLParser
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.concurrency import claim_revision
from govoplan_core.core.templates import (
TemplateCompatibility,
TemplateCompatibilityError,
TemplateFieldRequirement,
TemplateNotFoundError,
TemplateOutputProfile,
TemplateRef,
TemplateRevisionRef,
)
from govoplan_core.security.time import utc_now
from govoplan_templates.backend.db.models import TemplateDefinition, TemplateRevision
from govoplan_templates.backend.schemas import TemplateCreateRequest, TemplateRevisionPayload, TemplateUpdateRequest
READ_SCOPE = "templates:template:read"
WRITE_SCOPE = "templates:template:write"
PUBLISH_SCOPE = "templates:template:publish"
RENDER_SCOPE = "templates:template:render"
ADMIN_SCOPE = "templates:template:admin"
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
def visible_templates_statement(principal: ApiPrincipal, *, include_deleted: bool = False):
statement = select(TemplateDefinition).where(
TemplateDefinition.tenant_id == principal.tenant_id
)
if not principal.has(ADMIN_SCOPE):
statement = statement.where(
or_(
TemplateDefinition.scope_type == "tenant",
(
(TemplateDefinition.scope_type == "user")
& (TemplateDefinition.scope_id == principal.account_id)
),
(
(TemplateDefinition.scope_type == "group")
& TemplateDefinition.scope_id.in_(tuple(principal.group_ids) or ("",))
),
)
)
if not include_deleted:
statement = statement.where(TemplateDefinition.deleted_at.is_(None))
return statement
def list_templates(
session: Session,
principal: ApiPrincipal,
*,
query: str = "",
usage: str | None = None,
template_type: str | None = None,
locale: str | None = None,
include_deleted: bool = False,
limit: int = 200,
) -> list[TemplateDefinition]:
statement = visible_templates_statement(principal, include_deleted=include_deleted)
normalized_query = query.strip()
if normalized_query:
pattern = f"%{normalized_query}%"
statement = statement.where(
or_(
TemplateDefinition.name.ilike(pattern),
TemplateDefinition.slug.ilike(pattern),
TemplateDefinition.description.ilike(pattern),
)
)
if template_type:
statement = statement.where(TemplateDefinition.template_type == template_type)
rows = list(
session.scalars(
statement.order_by(TemplateDefinition.name, TemplateDefinition.id).limit(
max(1, min(limit, 500))
)
)
)
if not usage and not locale:
return rows
filtered: list[TemplateDefinition] = []
for item in rows:
revision = get_template_revision(session, item, published_preferred=True)
if usage and usage.strip().lower() not in revision.usages:
continue
if locale and revision.locale.lower() != locale.lower():
continue
filtered.append(item)
return filtered
def get_template(
session: Session,
principal: ApiPrincipal,
template_id: str,
*,
include_deleted: bool = False,
) -> TemplateDefinition:
item = session.scalar(
visible_templates_statement(principal, include_deleted=include_deleted).where(
TemplateDefinition.id == template_id
)
)
if item is None:
raise TemplateNotFoundError("Template not found.")
return item
def get_template_revision(
session: Session,
definition: TemplateDefinition,
*,
revision: int | None = None,
published_preferred: bool = False,
) -> TemplateRevision:
if revision is not None:
revision_number = revision
elif published_preferred and definition.published_revision_id:
published = session.get(TemplateRevision, definition.published_revision_id)
if published is not None and published.template_id == definition.id:
return published
revision_number = definition.current_revision
else:
revision_number = definition.current_revision
item = session.scalar(
select(TemplateRevision).where(
TemplateRevision.template_id == definition.id,
TemplateRevision.tenant_id == definition.tenant_id,
TemplateRevision.revision == revision_number,
)
)
if item is None:
raise TemplateNotFoundError("Template revision not found.")
return item
def list_template_revisions(
session: Session,
definition: TemplateDefinition,
) -> list[TemplateRevision]:
return list(
session.scalars(
select(TemplateRevision)
.where(
TemplateRevision.tenant_id == definition.tenant_id,
TemplateRevision.template_id == definition.id,
)
.order_by(TemplateRevision.revision.desc())
.limit(500)
)
)
def create_template(
session: Session,
principal: ApiPrincipal,
payload: TemplateCreateRequest,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_requested_scope(principal, payload.scope_type, payload.scope_id)
slug = _slug(payload.slug or payload.name)
_ensure_unique_slug(
session,
principal,
slug=slug,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
definition = TemplateDefinition(
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
name=payload.name.strip(),
slug=slug,
description=_text(payload.description),
template_type=payload.template_type,
status="draft",
current_revision_id="pending",
current_revision=1,
resource_revision=1,
created_by_account_id=principal.account_id,
updated_by_account_id=principal.account_id,
metadata_={},
)
session.add(definition)
session.flush()
revision = _create_revision(
session,
principal,
definition=definition,
revision_number=1,
payload=payload,
)
definition.current_revision_id = revision.id
session.flush()
return definition, revision
def update_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
payload: TemplateUpdateRequest,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_mutable_scope(principal, definition)
_ensure_requested_scope(principal, payload.scope_type, payload.scope_id)
slug = _slug(payload.slug or payload.name)
_ensure_unique_slug(
session,
principal,
slug=slug,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
exclude_id=definition.id,
)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=payload.base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path=f"/api/v1/templates/{definition.id}",
)
definition.resource_revision = next_resource_revision
definition.scope_type = payload.scope_type
definition.scope_id = payload.scope_id
definition.name = payload.name.strip()
definition.slug = slug
definition.description = _text(payload.description)
definition.template_type = payload.template_type
definition.current_revision += 1
definition.current_revision_id = "pending"
definition.status = "draft"
definition.updated_by_account_id = principal.account_id
revision = _create_revision(
session,
principal,
definition=definition,
revision_number=definition.current_revision,
payload=payload,
)
definition.current_revision_id = revision.id
session.flush()
return definition, revision
def publish_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
*,
revision: int | None,
base_revision: int,
) -> tuple[TemplateDefinition, TemplateRevision]:
_ensure_mutable_scope(principal, definition)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path=f"/api/v1/templates/{definition.id}",
)
item_revision = get_template_revision(session, definition, revision=revision)
now = utc_now()
item_revision.published_at = now
item_revision.published_by_account_id = principal.account_id
definition.published_revision_id = item_revision.id
definition.status = "active"
definition.resource_revision = next_resource_revision
definition.updated_by_account_id = principal.account_id
session.add(item_revision)
session.add(definition)
session.flush()
return definition, item_revision
def delete_template(
session: Session,
principal: ApiPrincipal,
definition: TemplateDefinition,
*,
base_revision: int,
) -> TemplateDefinition:
_ensure_mutable_scope(principal, definition)
next_resource_revision = claim_revision(
session,
model=TemplateDefinition,
filters=(
TemplateDefinition.id == definition.id,
TemplateDefinition.tenant_id == definition.tenant_id,
TemplateDefinition.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=base_revision,
resource_type="template_definition",
resource_id=definition.id,
refresh_path="/api/v1/templates",
)
definition.resource_revision = next_resource_revision
definition.status = "deleted"
definition.deleted_at = utc_now()
definition.updated_by_account_id = principal.account_id
session.flush()
return definition
def compatibility(
revision: TemplateRevision,
*,
usage: str | None,
output_format: str | None,
available_fields: Mapping[str, str] | Sequence[str],
) -> TemplateCompatibility:
field_types = (
{str(key): str(value) for key, value in available_fields.items()}
if isinstance(available_fields, Mapping)
else {str(item): "unknown" for item in available_fields}
)
normalized_usage = usage.strip().lower() if usage else None
diagnostics: list[dict[str, object]] = []
missing: list[str] = []
incompatible: list[str] = []
if normalized_usage and normalized_usage not in revision.usages:
diagnostics.append(
{
"code": "template.usage_incompatible",
"severity": "error",
"message": f"This template is not published for {normalized_usage}.",
}
)
formats = {str(item.get("output_format")) for item in revision.output_profiles}
if output_format and output_format not in formats:
diagnostics.append(
{
"code": "template.output_format_incompatible",
"severity": "error",
"message": f"This template does not provide {output_format} output.",
}
)
for requirement in revision.required_fields:
path = str(requirement.get("path") or "")
if not path or not bool(requirement.get("required", True)):
continue
if path not in field_types:
missing.append(path)
continue
actual = field_types[path]
expected = str(requirement.get("value_type") or "string")
if actual not in {"unknown", expected} and not (
expected == "number" and actual in {"integer", "number"}
):
incompatible.append(path)
if missing:
diagnostics.append(
{
"code": "template.required_fields_missing",
"severity": "error",
"message": f"Missing required fields: {', '.join(sorted(missing))}.",
}
)
if incompatible:
diagnostics.append(
{
"code": "template.field_types_incompatible",
"severity": "error",
"message": f"Fields have incompatible types: {', '.join(sorted(incompatible))}.",
}
)
return TemplateCompatibility(
compatible=not diagnostics,
template_id=revision.template_id,
revision_id=revision.id,
usage=normalized_usage,
output_format=output_format,
missing_fields=tuple(sorted(missing)),
incompatible_fields=tuple(sorted(incompatible)),
diagnostics=tuple(diagnostics),
)
def template_ref(
definition: TemplateDefinition,
revision: TemplateRevision,
*,
read_only: bool = False,
) -> TemplateRef:
return TemplateRef(
id=definition.id,
tenant_id=definition.tenant_id,
name=definition.name,
slug=definition.slug,
template_type=definition.template_type, # type: ignore[arg-type]
status=definition.status,
current_revision=definition.current_revision,
current_revision_id=definition.current_revision_id,
published_revision_id=definition.published_revision_id,
description=definition.description,
scope_type=definition.scope_type,
scope_id=definition.scope_id,
read_only=read_only,
updated_at=definition.updated_at,
revision=revision_ref(revision),
metadata=dict(definition.metadata_ or {}),
)
def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
return TemplateRevisionRef(
id=revision.id,
template_id=revision.template_id,
revision=revision.revision,
definition_hash=revision.definition_hash,
template_type=revision.template_type, # type: ignore[arg-type]
usages=tuple(revision.usages or []),
locale=revision.locale,
required_fields=tuple(
TemplateFieldRequirement(**item) for item in revision.required_fields
),
output_profiles=tuple(
TemplateOutputProfile(**item) for item in revision.output_profiles
),
published_at=revision.published_at,
provenance={
"module": "templates",
"created_by_account_id": revision.created_by_account_id,
},
)
def referenced_fields(revision: TemplateRevision) -> tuple[str, ...]:
content = f"{revision.content_text or ''}\n{revision.content_html or ''}"
return tuple(sorted(set(_TOKEN_PATTERN.findall(content))))
def _create_revision(
session: Session,
principal: ApiPrincipal,
*,
definition: TemplateDefinition,
revision_number: int,
payload: TemplateRevisionPayload,
) -> TemplateRevision:
output_profiles = [item.model_dump(mode="json") for item in payload.output_profiles]
if not output_profiles:
output_profiles = _default_output_profiles(payload.template_type)
sanitized_html = sanitize_template_html(payload.content_html)
definition_payload = {
"template_type": payload.template_type,
"usages": payload.usages,
"locale": payload.locale,
"required_fields": [item.model_dump(mode="json") for item in payload.required_fields],
"output_profiles": output_profiles,
"content_text": payload.content_text,
"content_html": sanitized_html,
"layout": payload.layout,
"metadata": payload.metadata,
}
revision = TemplateRevision(
tenant_id=principal.tenant_id,
template_id=definition.id,
revision=revision_number,
definition_hash=_canonical_hash(definition_payload),
template_type=payload.template_type,
usages=list(payload.usages),
locale=payload.locale,
required_fields=definition_payload["required_fields"],
output_profiles=output_profiles,
content_text=payload.content_text,
content_html=sanitized_html,
layout=dict(payload.layout),
metadata_=dict(payload.metadata),
created_by_account_id=principal.account_id,
)
session.add(revision)
session.flush()
return revision
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
media = "A4"
if template_type == "envelope":
media = "DL"
return [
{
"id": "print-html",
"label": "Printable HTML",
"output_format": "html",
"media_type": "text/html",
"channel": "print",
"capabilities": ["browser_print", template_type],
"page": {"size": media},
},
{
"id": "plain-text",
"label": "Plain text",
"output_format": "text",
"media_type": "text/plain",
"channel": "download",
"capabilities": ["deterministic_text"],
"page": {},
},
]
def _ensure_unique_slug(
session: Session,
principal: ApiPrincipal,
*,
slug: str,
scope_type: str,
scope_id: str | None,
exclude_id: str | None = None,
) -> None:
statement = select(TemplateDefinition.id).where(
TemplateDefinition.tenant_id == principal.tenant_id,
TemplateDefinition.scope_type == scope_type,
TemplateDefinition.slug == slug,
TemplateDefinition.deleted_at.is_(None),
)
if scope_id is None:
statement = statement.where(TemplateDefinition.scope_id.is_(None))
else:
statement = statement.where(TemplateDefinition.scope_id == scope_id)
if exclude_id:
statement = statement.where(TemplateDefinition.id != exclude_id)
if session.scalar(statement) is not None:
raise TemplateCompatibilityError("A template with this slug already exists in the selected scope.")
def _ensure_requested_scope(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> None:
if principal.has(ADMIN_SCOPE):
return
if scope_type == "tenant":
return
if scope_type == "user" and scope_id == principal.account_id:
return
if scope_type == "group" and scope_id in principal.group_ids:
return
raise TemplateCompatibilityError("The requested template scope is not writable by this principal.")
def _ensure_mutable_scope(principal: ApiPrincipal, definition: TemplateDefinition) -> None:
_ensure_requested_scope(principal, definition.scope_type, definition.scope_id)
def _slug(value: str) -> str:
normalized = re.sub(r"[^a-z0-9_.-]+", "-", value.strip().lower()).strip("-.")
if not normalized:
raise TemplateCompatibilityError("Template slug cannot be empty.")
return normalized[:160]
def _text(value: str | None) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _canonical_hash(value: object) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class _TemplateHtmlSanitizer(HTMLParser):
allowed_tags = {
"a", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "li", "ol", "p", "pre", "span", "strong",
"table", "tbody", "td", "th", "thead", "tr", "u", "ul",
}
void_tags = {"br", "hr"}
blocked_tags = {"script", "style", "iframe", "object", "embed", "svg", "math"}
allowed_attrs = {"a": {"href", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan"}}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.blocked_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if tag in self.blocked_tags:
self.blocked_depth += 1
return
if self.blocked_depth or tag not in self.allowed_tags:
return
clean_attrs: list[str] = []
for name, raw_value in attrs:
name = name.lower()
value = str(raw_value or "")
if name not in self.allowed_attrs.get(tag, set()):
continue
if name == "href" and not _safe_href(value):
continue
clean_attrs.append(f' {name}="{escape(value, quote=True)}"')
self.parts.append(f"<{tag}{''.join(clean_attrs)}>")
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in self.blocked_tags:
self.blocked_depth = max(0, self.blocked_depth - 1)
return
if not self.blocked_depth and tag in self.allowed_tags and tag not in self.void_tags:
self.parts.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
if not self.blocked_depth:
self.parts.append(escape(data, quote=False))
def sanitize_template_html(value: str | None) -> str | None:
if not value or not value.strip():
return None
parser = _TemplateHtmlSanitizer()
parser.feed(value)
parser.close()
return "".join(parser.parts).strip() or None
def _safe_href(value: str) -> bool:
normalized = value.strip().lower()
return normalized.startswith(("https://", "http://", "mailto:", "#", "/"))
__all__ = [
"ADMIN_SCOPE",
"PUBLISH_SCOPE",
"READ_SCOPE",
"RENDER_SCOPE",
"WRITE_SCOPE",
"compatibility",
"create_template",
"delete_template",
"get_template",
"get_template_revision",
"list_template_revisions",
"list_templates",
"publish_template",
"referenced_fields",
"revision_ref",
"sanitize_template_html",
"template_ref",
"update_template",
]
+1
View File
@@ -0,0 +1 @@
+281
View File
@@ -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 = "<p>Hello {{name}}</p><p>{{postal.address}}</p>",
) -> TemplateCreateRequest:
return TemplateCreateRequest.model_validate(
{
"name": name,
"template_type": template_type,
"usages": ["campaign.postal"],
"locale": "de-DE",
"required_fields": [
{"path": "name", "value_type": "string", "required": True},
{"path": "postal.address", "value_type": "string", "required": True},
],
"content_html": body,
"layout": {
"page_size": "A4",
"margin_mm": 15,
"columns": 2,
"rows": 2,
},
}
)
class _Registry:
def __init__(self, capability=None) -> None:
self._capability = capability
def has_capability(self, name: str) -> bool:
return self._capability is not None and name == "files.artifact_store"
def capability(self, name: str):
return self._capability if name == "files.artifact_store" else None
class _ArtifactStore:
def __init__(self) -> None:
self.request = None
def store_artifact(self, session, principal, *, request):
del session, principal
self.request = request
return ManagedArtifactRef(
file_asset_id="file-1",
file_version_id="version-1",
filename=request.filename,
display_path=f"Generated/Templates/{request.filename}",
content_type=request.content_type,
size_bytes=len(request.payload),
sha256=hashlib.sha256(request.payload).hexdigest(),
provenance={"module": "files", "managed": True},
)
class TemplateServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[
TemplateDefinition.__table__,
TemplateRevision.__table__,
TemplateRender.__table__,
],
)
def tearDown(self) -> None:
reset_database(dispose=True)
def test_catalogue_exposes_typed_contract_and_immutable_revisions(self) -> None:
with self.database.session() as session:
item, first = create_template(session, principal(), payload())
update = TemplateUpdateRequest.model_validate(
{
**payload(body="<p>Dear {{name}}</p><p>{{postal.address}}</p>").model_dump(mode="json"),
"base_revision": 1,
}
)
item, second = update_template(session, principal(), item, update)
session.commit()
refs = SqlTemplateCatalog().list_templates(
session,
principal(),
usage="campaign.postal",
)
self.assertEqual(1, len(refs))
self.assertEqual("serial_letter", refs[0].template_type)
self.assertEqual(("campaign.postal",), refs[0].revision.usages)
self.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
self.assertNotEqual(first.definition_hash, second.definition_hash)
self.assertEqual(2, second.revision)
def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
frozen = (
{"name": "Ada", "postal": {"address": "Street 1"}},
{"name": "Grace", "postal": {"address": "Street 2"}},
)
with self.database.session() as session:
item, _ = create_template(session, principal(), payload())
item, revision = publish_template(
session,
principal(),
item,
revision=1,
base_revision=1,
)
request = TemplateRenderRequest(
template_id=item.id,
revision=revision.revision,
usage="campaign.postal",
items=frozen,
input_snapshot={"provider": "dist_lists", "snapshot_id": "snapshot-1"},
mode="final",
idempotency_key="campaign-1:postal-output-1",
)
first = render_template(session, principal(), registry=_Registry(), request=request)
second = render_template(session, principal(), registry=_Registry(), request=request)
session.commit()
self.assertEqual(first.render_id, second.render_id)
self.assertEqual(first.input_hash, second.input_hash)
self.assertEqual(first.output_sha256, second.output_sha256)
self.assertEqual(2, first.item_count)
self.assertEqual(2, first.page_count)
self.assertEqual("bounded_download", first.artifact.kind)
self.assertIn(b"Ada", first.payload)
self.assertIn(b"Grace", first.payload)
def test_label_sheet_page_count_and_missing_fields(self) -> None:
with self.database.session() as session:
item, _ = create_template(
session,
principal(),
payload("Address labels", template_type="label_sheet"),
)
with self.assertRaises(TemplateCompatibilityError):
render_template(
session,
principal(),
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=({"name": "Missing address"},),
),
)
result = render_template(
session,
principal(),
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=tuple(
{"name": f"Person {index}", "postal": {"address": f"Street {index}"}}
for index in range(5)
),
),
)
self.assertEqual(2, result.page_count)
def test_optional_files_store_receives_hashes_without_templates_payload_copy(self) -> None:
store = _ArtifactStore()
with self.database.session() as session:
item, _ = create_template(session, principal(), payload())
item, revision = publish_template(session, principal(), item, revision=1, base_revision=1)
result = render_template(
session,
principal(),
registry=_Registry(store),
request=TemplateRenderRequest(
template_id=item.id,
revision=revision.revision,
usage="campaign.postal",
items=({"name": "Ada", "postal": {"address": "Street 1"}},),
input_snapshot={"snapshot_id": "snapshot-1"},
mode="final",
idempotency_key="managed-output-1",
persist_to_files=True,
),
)
session.commit()
row = session.get(TemplateRender, result.render_id)
self.assertEqual("managed_file", result.artifact.kind)
self.assertIsNone(row.payload)
self.assertEqual(result.output_sha256, store.request.metadata["output_sha256"])
self.assertNotIn("Ada", str(store.request.metadata))
def test_tenant_isolation_and_html_sanitization(self) -> None:
self.assertEqual("<p>Safe</p>", sanitize_template_html("<p>Safe</p><script>alert(1)</script>"))
self.assertEqual("<a>Unsafe</a>", sanitize_template_html('<a href="javascript:alert(1)">Unsafe</a>'))
with self.database.session() as session:
item, _ = create_template(session, principal("tenant-1"), payload())
session.commit()
self.assertIsNone(
SqlTemplateCatalog().get_template(
session,
principal("tenant-2"),
template_id=item.id,
)
)
class TemplateManifestTests(unittest.TestCase):
def test_manifest_announces_provider_neutral_capabilities(self) -> None:
from govoplan_templates.backend.manifest import get_manifest
manifest = get_manifest()
self.assertEqual("templates", manifest.id)
self.assertFalse(manifest.dependencies)
self.assertIn("files", manifest.optional_dependencies)
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories)
self.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -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
}
}
}
+266
View File
@@ -0,0 +1,266 @@
import {
apiDownload,
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type TemplateType =
| "label"
| "label_sheet"
| "envelope"
| "serial_letter"
| "form_letter"
| "list_layout"
| "email"
| "generic";
export type TemplateFieldType =
| "string"
| "integer"
| "number"
| "boolean"
| "date"
| "datetime"
| "object"
| "array";
export type TemplateFieldRequirement = {
path: string;
value_type: TemplateFieldType;
label?: string | null;
required: boolean;
description?: string | null;
};
export type TemplateOutputProfile = {
id: string;
label: string;
output_format: "html" | "text";
media_type: string;
channel: string;
capabilities: string[];
page: Record<string, unknown>;
};
export type TemplateRevision = {
id: string;
revision: number;
definition_hash: string;
template_type: TemplateType;
usages: string[];
locale: string;
required_fields: TemplateFieldRequirement[];
output_profiles: TemplateOutputProfile[];
content_text?: string | null;
content_html?: string | null;
layout: Record<string, unknown>;
metadata: Record<string, unknown>;
created_by_account_id?: string | null;
published_at?: string | null;
published_by_account_id?: string | null;
created_at: string;
};
export type TemplateDefinition = {
id: string;
tenant_id: string;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
template_type: TemplateType;
status: string;
current_revision: number;
resource_revision: number;
strong_etag: string;
current_revision_id: string;
published_revision_id?: string | null;
read_only: boolean;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
revision: TemplateRevision;
};
export type TemplatePayload = {
name: string;
slug?: string | null;
description?: string | null;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
template_type: TemplateType;
usages: string[];
locale: string;
required_fields: TemplateFieldRequirement[];
output_profiles: TemplateOutputProfile[];
content_text?: string | null;
content_html?: string | null;
layout: Record<string, unknown>;
metadata: Record<string, unknown>;
};
export type TemplateCompatibility = {
compatible: boolean;
template_id: string;
revision_id: string;
usage?: string | null;
output_format?: string | null;
missing_fields: string[];
incompatible_fields: string[];
diagnostics: Array<Record<string, unknown>>;
};
export type TemplateArtifact = {
kind: "managed_file" | "bounded_download";
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
file_asset_id?: string | null;
file_version_id?: string | null;
download_path?: string | null;
provenance: Record<string, unknown>;
};
export type TemplateRender = {
render_id: string;
template_id: string;
revision_id: string;
revision: number;
template_hash: string;
input_hash: string;
renderer_version: string;
output_format: "html" | "text";
content_type: string;
filename: string;
item_count: number;
page_count: number;
output_sha256: string;
output_size_bytes: number;
diagnostics: Array<Record<string, unknown>>;
artifact?: TemplateArtifact | null;
generated_at?: string | null;
};
export async function listTemplates(settings: ApiSettings): Promise<TemplateDefinition[]> {
const result = await apiFetch<{ items: TemplateDefinition[] }>(
settings,
apiPath("/api/v1/templates", { limit: 500 })
);
return result.items;
}
export function listTemplateRevisions(
settings: ApiSettings,
templateId: string
): Promise<TemplateRevision[]> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(templateId)}/revisions`);
}
export async function listTemplateRenders(
settings: ApiSettings,
templateId: string
): Promise<TemplateRender[]> {
const result = await apiFetch<{ items: TemplateRender[] }>(
settings,
apiPath("/api/v1/templates/renders/history", { template_id: templateId, limit: 100 })
);
return result.items;
}
export function createTemplate(settings: ApiSettings, payload: TemplatePayload): Promise<TemplateDefinition> {
return apiFetch(settings, "/api/v1/templates", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateTemplate(
settings: ApiSettings,
item: TemplateDefinition,
payload: TemplatePayload
): Promise<TemplateDefinition> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
method: "PUT",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ ...payload, base_revision: item.resource_revision })
});
}
export function publishTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<TemplateDefinition> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/publish`, {
method: "POST",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ revision: item.current_revision, base_revision: item.resource_revision })
});
}
export function deleteTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<void> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
method: "DELETE",
headers: { "If-Match": item.strong_etag },
body: JSON.stringify({ base_revision: item.resource_revision })
});
}
export function checkTemplateCompatibility(
settings: ApiSettings,
item: TemplateDefinition,
usage: string,
availableFields: Record<string, string>,
outputFormat: "html" | "text"
): Promise<TemplateCompatibility> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/compatibility`, {
method: "POST",
body: JSON.stringify({
revision: item.current_revision,
usage: usage || null,
output_format: outputFormat,
available_fields: availableFields
})
});
}
export function renderTemplate(
settings: ApiSettings,
item: TemplateDefinition,
options: {
usage: string;
outputFormat: "html" | "text";
items: Array<Record<string, unknown>>;
final: boolean;
persistToFiles: boolean;
}
): Promise<TemplateRender> {
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/render`, {
method: "POST",
body: JSON.stringify({
revision: item.current_revision,
usage: options.usage || null,
output_format: options.outputFormat,
items: options.items,
input_snapshot: {
source: "templates.webui",
supplied_item_count: options.items.length
},
mode: options.final ? "final" : "preview",
idempotency_key: options.final ? `templates-ui:${crypto.randomUUID()}` : null,
persist_to_files: options.persistToFiles
})
});
}
export function downloadTemplateRender(settings: ApiSettings, render: TemplateRender): Promise<void> {
if (render.artifact?.kind === "bounded_download") {
return apiDownload(
settings,
`/api/v1/templates/renders/${encodeURIComponent(render.render_id)}/download`,
render.filename
);
}
const path = render.artifact?.download_path;
if (!path) return Promise.reject(new Error("This render has no downloadable artifact."));
return apiDownload(settings, path, render.filename);
}
@@ -0,0 +1,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<TemplateDefinition[]>([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<TemplatePayload>(emptyPayload());
const [savedKey, setSavedKey] = useState("");
const [view, setView] = useState<WorkspaceView>("definition");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createType, setCreateType] = useState<TemplateType>("form_letter");
const [deleteOpen, setDeleteOpen] = useState(false);
const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}');
const [usage, setUsage] = useState("campaign.postal");
const [outputFormat, setOutputFormat] = useState<"html" | "text">("html");
const [persistToFiles, setPersistToFiles] = useState(false);
const [compatibility, setCompatibility] = useState<TemplateCompatibility | null>(null);
const [render, setRender] = useState<TemplateRender | null>(null);
const [revisions, setRevisions] = useState<TemplateRevision[]>([]);
const [renders, setRenders] = useState<TemplateRender[]>([]);
const 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 (
<main className="templates-page">
<div className="templates-shell">
<aside className="templates-sidebar">
<div className="templates-sidebar-toolbar">
<strong>Template library</strong>
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} onClick={() => setCreateOpen(true)} />
</div>
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
<div className="templates-list">
{visibleItems.map((item) => (
<button
key={item.id}
type="button"
className={item.id === selectedId ? "is-selected" : ""}
onClick={() => {
if (item.id === selectedId) return;
setSelectedId(item.id);
applyItem(item);
}}
>
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
<StatusBadge status={item.status} label={item.status} />
</button>
))}
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
</div>
</aside>
<section className="templates-workspace">
<header className="templates-workspace-toolbar">
<span className="templates-current-title">
<strong>{selected?.name ?? "Select a template"}</strong>
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
</span>
<div className="templates-toolbar-actions">
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} onClick={() => void reload(selectedId)} />
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} onClick={() => void save()}><Save size={16} /> Save revision</Button>
<Button disabled={!selected || !canPublish || dirty || busy} onClick={() => void publish()}><FileCheck2 size={16} /> Publish</Button>
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} onClick={() => setDeleteOpen(true)} />
<SegmentedControl
value={view}
onChange={setView}
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
ariaLabel="Template workspace"
/>
</div>
</header>
<div className="templates-alerts">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
</div>
<LoadingFrame loading={loading} label="Loading templates">
<div className="templates-content">
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <>
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
</> : <>
<PreviewPanel
item={selected}
sampleText={sampleText}
usage={usage}
outputFormat={outputFormat}
persistToFiles={persistToFiles}
compatibility={compatibility}
render={render}
disabled={busy || dirty || !canRender}
onSampleText={setSampleText}
onUsage={setUsage}
onOutputFormat={setOutputFormat}
onPersistToFiles={setPersistToFiles}
onRender={runRender}
onDownload={() => render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))}
/>
<RenderHistory renders={renders} settings={settings} onError={setError} />
</>}
</div>
</LoadingFrame>
</section>
</div>
<Dialog open={createOpen} title="Add template" onClose={() => setCreateOpen(false)} footer={<><Button onClick={() => setCreateOpen(false)}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} onClick={() => void create()}>Create</Button></>}>
<div className="templates-dialog-form">
<FormField label="Name"><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
<FormField label="Type"><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
</div>
</Dialog>
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
</main>
);
}
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
<div className="templates-history-list">
{revisions.map((revision) => <div key={revision.id}>
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
<span className="templates-history-badges">
{revision.id === currentRevisionId && <StatusBadge status="current" label="Current" />}
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
</span>
</div>)}
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>}
</div>
</section>;
}
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
<div className="templates-history-list">
{renders.map((item) => <div key={item.render_id}>
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
</div>)}
{!renders.length && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
</div>
</section>;
}
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
const update = <K extends keyof TemplatePayload>(key: K, value: TemplatePayload[K]) => onChange({ ...draft, [key]: value });
const scopeOptions = [
{ value: "tenant:", label: "Tenant" },
{ value: `user:${auth.user.account_id}`, label: "Only me" },
...auth.groups.map((group) => ({ value: `group:${group.id}`, label: `Group: ${group.name}` }))
];
const scopeValue = `${draft.scope_type}:${draft.scope_id ?? ""}`;
const layout = draft.layout;
return (
<div className="templates-definition">
<div className="templates-definition-fields">
<FormField label="Name"><input disabled={disabled} value={draft.name} onChange={(event) => update("name", event.target.value)} /></FormField>
<FormField label="Type"><select disabled={disabled} value={draft.template_type} onChange={(event) => update("template_type", event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
<FormField label="Locale"><input disabled={disabled} value={draft.locale} onChange={(event) => update("locale", event.target.value)} /></FormField>
<FormField label="Visibility"><select disabled={disabled} value={scopeValue} onChange={(event) => { const [scopeType, scopeId] = event.target.value.split(":", 2); onChange({ ...draft, scope_type: scopeType as TemplatePayload["scope_type"], scope_id: scopeId || null }); }}>{scopeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></FormField>
<FormField label="Usages" help="Comma-separated capability contexts, for example campaign.postal or addresses.labels."><input disabled={disabled} value={draft.usages.join(", ")} onChange={(event) => update("usages", splitValues(event.target.value))} /></FormField>
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
</div>
<section className="templates-section">
<div className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></div>
<div className="templates-fields-table">
{draft.required_fields.map((field, index) => (
<div className="templates-field-row" key={`${index}:${field.path}`}>
<input disabled={disabled} value={field.path} placeholder="recipient.address" aria-label="Field path" onChange={(event) => updateField(draft, index, { path: event.target.value }, onChange)} />
<select disabled={disabled} value={field.value_type} aria-label="Field type" onChange={(event) => updateField(draft, index, { value_type: event.target.value as TemplateFieldType }, onChange)}>{FIELD_TYPES.map((value) => <option key={value} value={value}>{value}</option>)}</select>
<input disabled={disabled} value={field.label ?? ""} placeholder="Label" aria-label="Field label" onChange={(event) => updateField(draft, index, { label: event.target.value || null }, onChange)} />
<ToggleSwitch checked={field.required} label="Required" disabled={disabled} onChange={(checked) => updateField(draft, index, { required: checked }, onChange)} />
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
</div>
))}
{!draft.required_fields.length && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
</div>
</section>
<section className="templates-section">
<div className="templates-section-heading"><strong>Page and media</strong></div>
<div className="templates-layout-fields">
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
{draft.template_type === "label_sheet" && <>
<FormField label="Columns"><input type="number" min="1" max="12" disabled={disabled} value={Number(layout.columns ?? 3)} onChange={(event) => update("layout", { ...layout, columns: Number(event.target.value) })} /></FormField>
<FormField label="Rows"><input type="number" min="1" max="30" disabled={disabled} value={Number(layout.rows ?? 8)} onChange={(event) => update("layout", { ...layout, rows: Number(event.target.value) })} /></FormField>
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
</>}
</div>
</section>
<section className="templates-section templates-body-section">
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
</section>
</div>
);
}
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, 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 (
<div className="templates-preview">
<section className="templates-section">
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
<div className="templates-preview-controls">
<FormField label="Usage"><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Output"><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
<ToggleSwitch checked={persistToFiles} label="Store in Files when available" onChange={onPersistToFiles} />
</div>
<textarea className="templates-sample" value={sampleText} onChange={(event) => onSampleText(event.target.value)} spellCheck={false} aria-label="Sample item JSON" />
<div className="templates-preview-actions">
<Button disabled={disabled} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={!item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
</div>
</section>
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
{compatibility.compatible
? "The selected usage, output profile, and supplied fields are compatible."
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
</DismissibleAlert>}
{render && <section className="templates-section templates-render-result">
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
<dl>
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
<div><dt>Output</dt><dd>{shortHash(render.output_sha256)}</dd></div>
<div><dt>Renderer</dt><dd>{render.renderer_version}</dd></div>
<div><dt>Items / pages</dt><dd>{render.item_count} / {render.page_count}</dd></div>
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
</dl>
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
</section>}
</div>
);
}
function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePayload {
return {
name: "",
description: null,
scope_type: "tenant",
scope_id: null,
template_type: templateType,
usages: [templateType === "email" ? "campaign.email" : "campaign.postal"],
locale: "en",
required_fields: [],
output_profiles: [],
content_text: null,
content_html: "<p>Hello {{name}},</p><p></p>",
layout: { page_size: pageSizeForType(templateType), margin_mm: 15 },
metadata: {}
};
}
function payloadFromItem(item: TemplateDefinition): TemplatePayload {
return {
name: item.name,
slug: item.slug,
description: item.description ?? null,
scope_type: item.scope_type,
scope_id: item.scope_id ?? null,
template_type: item.template_type,
usages: [...item.revision.usages],
locale: item.revision.locale,
required_fields: item.revision.required_fields.map((field) => ({ ...field })),
output_profiles: item.revision.output_profiles.map((profile) => ({ ...profile, capabilities: [...profile.capabilities], page: { ...profile.page } })),
content_text: item.revision.content_text ?? null,
content_html: item.revision.content_html ?? null,
layout: { ...item.revision.layout },
metadata: { ...item.revision.metadata }
};
}
function emptyField(): TemplateFieldRequirement {
return { path: "", value_type: "string", label: null, required: true, description: null };
}
function updateField(draft: TemplatePayload, index: number, patch: Partial<TemplateFieldRequirement>, onChange: (draft: TemplatePayload) => void) {
onChange({ ...draft, required_fields: draft.required_fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) });
}
function pageSizeForType(type: TemplateType): string { return type === "envelope" ? "DL" : "A4"; }
function typeLabel(type: TemplateType): string { return TEMPLATE_TYPES.find((item) => item.value === type)?.label ?? type; }
function splitValues(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim().toLocaleLowerCase()).filter(Boolean))]; }
function draftKey(value: TemplatePayload): string { return JSON.stringify(value); }
function shortHash(value: string): string { return value.slice(0, 12); }
function parseSample(value: string): Record<string, unknown> {
const parsed: unknown = JSON.parse(value);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("Sample input must be one JSON object.");
return parsed as Record<string, unknown>;
}
function flattenFieldTypes(value: Record<string, unknown>, prefix = "", result: Record<string, string> = {}): Record<string, string> {
for (const [key, item] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(item)) result[path] = "array";
else if (item !== null && typeof item === "object") {
result[path] = "object";
flattenFieldTypes(item as Record<string, unknown>, path, result);
} else if (typeof item === "number") result[path] = Number.isInteger(item) ? "integer" : "number";
else result[path] = typeof item;
}
return result;
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: unknown };
return typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail ?? parsed);
} catch { return error.message; }
}
return error instanceof Error ? error.message : "The template operation failed.";
}
+4
View File
@@ -0,0 +1,4 @@
export { default } from "./module";
export * from "./module";
export * from "./api/templates";
export { default as TemplatesPage } from "./features/templates/TemplatesPage";
+35
View File
@@ -0,0 +1,35 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/templates.css";
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const readScopes = [
"templates:template:read",
"templates:template:write",
"templates:template:publish",
"templates:template:render",
"templates:template:admin"
];
export const templatesModule: PlatformWebModule = {
id: "templates",
label: "Templates",
version: "0.1.14",
optionalDependencies: ["files", "dist_lists", "campaigns", "audit"],
navItems: [{
to: "/templates",
label: "Templates",
iconName: "layout-template",
anyOf: readScopes,
order: 75
}],
routes: [{
path: "/templates",
anyOf: readScopes,
order: 75,
render: ({ settings, auth }) => createElement(TemplatesPage, { settings, auth })
}]
};
export default templatesModule;
+114
View File
@@ -0,0 +1,114 @@
.templates-page {
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
.templates-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.templates-current-title { min-width: 0; flex: 1 1 auto; }
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
.templates-alerts:empty { display: none; }
.templates-alerts .alert { margin: 10px 0 0; }
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
.templates-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
.templates-section-heading small { color: var(--muted); font-weight: 400; }
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-fields-table { overflow: auto; padding: 8px; }
.templates-field-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(110px, .6fr) minmax(160px, 1fr) auto 34px; align-items: center; gap: 8px; min-width: 710px; padding: 4px 0; }
.templates-field-row input, .templates-field-row select { width: 100%; }
.templates-layout-fields { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; padding: 12px; }
.templates-body-section .wysiwyg-editor { margin: 12px; }
.templates-preview { max-width: 1100px; margin: 0 auto; }
.templates-preview-controls { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(250px, 1.2fr) auto; align-items: end; gap: 14px; padding: 12px; }
.templates-sample { display: block; width: calc(100% - 24px); min-height: 260px; margin: 0 12px; padding: 10px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.templates-preview-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px; }
.templates-preview-actions .btn, .templates-render-result .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-render-result dl { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 10px; margin: 0; padding: 12px; }
.templates-render-result dl div { padding: 9px; border: var(--border-line); background: var(--panel-soft); }
.templates-render-result dt { color: var(--muted); font-size: 11px; text-transform: uppercase; }
.templates-render-result dd { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.templates-render-result > p { margin: 0; padding: 0 12px 12px; color: var(--muted); }
.templates-history-list { max-height: 260px; overflow: auto; padding: 6px; }
.templates-history-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 50px; padding: 7px 9px; border-bottom: var(--border-line); }
.templates-history-list > div:last-child { border-bottom: 0; }
.templates-history-list strong, .templates-history-list small { display: block; }
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-history-list .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
@media (max-width: 980px) {
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-preview-controls { grid-template-columns: 1fr; align-items: stretch; }
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
.templates-workspace { overflow: visible; }
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
.templates-toolbar-actions { flex-wrap: wrap; }
.templates-content { height: auto; overflow: visible; }
.templates-definition-fields, .templates-dialog-form, .templates-render-result dl { grid-template-columns: 1fr; }
}
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModuleLoaders: Array<{
packageName: string;
load: () => Promise<{ default: PlatformWebModule }>;
}>;
export default installedWebModuleLoaders;
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}