Implement typed template library and rendering
This commit is contained in:
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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."""
|
||||
+135
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user