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
+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 @@