10 Commits
Author SHA1 Message Date
zemion 69416faca9 fix(webui): bind template lifecycle controls to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:45 +02:00
zemion ee6fbc784f docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:41 +02:00
zemion dff2508698 docs(templates): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:27:26 +02:00
zemion 80001bbe98 feat(templates): add governed DSAR coverage 2026-08-21 13:24:13 +02:00
zemion 5de54ccd2e feat(templates): persist reusable content requirements 2026-08-19 21:54:54 +02:00
zemion 1c12359750 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 58be2482ec feat: align templates with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion 3856765520 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion cf4205f780 Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion 21ed6270a3 feat: add reusable content fragments 2026-08-07 14:53:34 +02:00
15 changed files with 1171 additions and 147 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-templates"
version = "0.1.18"
version = "0.1.21"
description = "GovOPlaN typed template library and deterministic printable rendering."
readme = "README.md"
requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Templates module."""
__version__ = "0.1.18"
__version__ = "0.1.21"
@@ -4,17 +4,30 @@ from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.templates import (
TemplateCatalogProvider,
TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef,
)
from govoplan_templates.backend.rendering import SqlTemplateRenderer
from govoplan_templates.backend.schemas import TemplateCreateRequest
from govoplan_templates.backend.service import (
READ_SCOPE,
WRITE_SCOPE,
compatibility,
create_template,
get_template,
get_template_revision,
list_templates,
@@ -108,6 +121,85 @@ class SqlTemplateCatalog(TemplateCatalogProvider):
)
class SqlTemplateContentLibrary(TemplateContentLibraryProvider):
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef:
sql_session, api_principal = _context(session, principal)
_require_write(api_principal)
if request.template_type not in {"content_fragment", "email", "generic"}:
raise ValueError(
"Reusable content drafts must be a content fragment, email, or generic template."
)
item, revision = create_template(
sql_session,
api_principal,
TemplateCreateRequest(
name=request.name,
description=request.description,
scope_type=request.scope_type,
scope_id=request.scope_id,
template_type=request.template_type,
usages=list(request.usages),
locale=request.locale,
required_fields=[
{
"path": field.path,
"value_type": field.value_type,
"label": field.label,
"required": field.required,
"description": field.description,
}
for field in request.required_fields
],
output_profiles=[],
content_text=request.content_text,
content_html=request.content_html,
layout={},
metadata={
**dict(request.metadata),
"created_through": "templates.content_library",
},
),
)
audit_from_principal(
sql_session,
api_principal,
action="templates.template.created",
object_type="template",
object_id=item.id,
details={
"revision": revision.revision,
"definition_hash": revision.definition_hash,
"template_type": revision.template_type,
"usages": list(revision.usages or []),
"required_fields": [
str(field.get("path") or "")
for field in revision.required_fields or []
if field.get("path")
],
"source": "content_library_capability",
},
commit=False,
)
emit_platform_event(
sql_session,
PlatformEvent(
type="templates.template.created.v1",
module_id="templates",
actor=EventActorRef(type="account", id=api_principal.account_id),
tenant=EventTenantRef(id=api_principal.tenant_id),
resource=EventObjectRef(type="template", id=item.id),
classification="internal",
),
)
return template_ref(item, revision, read_only=False)
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
return SqlTemplateCatalog()
@@ -116,6 +208,10 @@ def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
return SqlTemplateRenderer(context.registry)
def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary:
return SqlTemplateContentLibrary()
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
if not isinstance(session, Session):
raise TypeError("Template catalogue access requires a SQLAlchemy session.")
@@ -137,6 +233,14 @@ def _require_read(principal: ApiPrincipal) -> None:
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.")
def _require_write(principal: ApiPrincipal) -> None:
if not any(
principal.has(scope)
for scope in (WRITE_SCOPE, "templates:template:admin")
):
raise PermissionError(f"Template draft creation requires {WRITE_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
@@ -147,6 +251,8 @@ def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -
__all__ = [
"SqlTemplateCatalog",
"SqlTemplateContentLibrary",
"catalog_capability",
"content_library_capability",
"renderer_capability",
]
@@ -0,0 +1,377 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
TEMPLATES_DSAR_CAPABILITY = dsar_capability_name("templates")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str
template_id: str | None
revision_id: str | None
render_id: str | None
@property
def narrowed(self) -> bool:
return bool(self.template_id or self.revision_id or self.render_id)
class TemplatesDsarProvider:
provider_id = "templates"
module_id = "templates"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if not selectors.narrowed or selectors.template_id:
query = db.query(TemplateDefinition).filter(
TemplateDefinition.tenant_id == tenant_id,
or_(
TemplateDefinition.created_by_account_id == selectors.account_id,
TemplateDefinition.updated_by_account_id == selectors.account_id,
),
)
if selectors.template_id:
query = query.filter(TemplateDefinition.id == selectors.template_id)
records.extend(
_definition_record(row, selectors.account_id)
for row in _limited(
query,
TemplateDefinition.created_at,
TemplateDefinition.id,
label="definition attribution",
)
)
if not selectors.narrowed or selectors.template_id or selectors.revision_id:
query = db.query(TemplateRevision).filter(
TemplateRevision.tenant_id == tenant_id,
or_(
TemplateRevision.created_by_account_id == selectors.account_id,
TemplateRevision.published_by_account_id == selectors.account_id,
),
)
if selectors.template_id:
query = query.filter(
TemplateRevision.template_id == selectors.template_id
)
if selectors.revision_id:
query = query.filter(TemplateRevision.id == selectors.revision_id)
records.extend(
_revision_record(row, selectors.account_id)
for row in _limited(
query,
TemplateRevision.created_at,
TemplateRevision.id,
label="revision attribution",
)
)
if not selectors.narrowed or selectors.template_id or selectors.render_id:
query = db.query(TemplateRender).filter(
TemplateRender.tenant_id == tenant_id,
TemplateRender.created_by_account_id == selectors.account_id,
)
if selectors.template_id:
query = query.filter(
TemplateRender.template_id == selectors.template_id
)
if selectors.render_id:
query = query.filter(TemplateRender.id == selectors.render_id)
records.extend(
_render_record(row)
for row in _limited(
query,
TemplateRender.created_at,
TemplateRender.id,
label="render attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Templates DSAR result limit exceeded; narrow selectors.")
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Templates DSAR subject selectors conflict.")
actions = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"templates:retain:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=(
record.retention_reason
or "Template lifecycle attribution remains evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Templates DSAR subject selectors conflict.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Templates DSAR publishes retain actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Template lifecycle attribution remains evidence.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("templates.account"),
references.get("access.account"),
),
"template_id": _coalesce(
references.get("templates.template"),
references.get("templates.template_id"),
),
"revision_id": _coalesce(
references.get("templates.revision"),
references.get("templates.revision_id"),
),
"render_id": _coalesce(
references.get("templates.render"), references.get("templates.render_id")
),
}
if any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional(values["account_id"])
if not account_id:
return None
return _Selectors(
account_id=account_id,
template_id=_optional(values["template_id"]),
revision_id=_optional(values["revision_id"]),
render_id=_optional(values["render_id"]),
)
def _definition_record(row: TemplateDefinition, account_id: str) -> DsarRecordRef:
activities = []
if row.created_by_account_id == account_id:
activities.append("created_template")
if row.updated_by_account_id == account_id:
activities.append("updated_template")
return _record(
resource_type="template_definition_actor_attribution",
resource_id=row.id,
title="Template-definition actor attribution",
data={
"template_id": row.id,
"template_type": row.template_type,
"scope_type": row.scope_type,
"status": row.status,
"current_revision": row.current_revision,
"resource_revision": row.resource_revision,
"activities": activities,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
"retired_at": _iso(row.deleted_at),
},
observed_at=row.updated_at,
)
def _revision_record(row: TemplateRevision, account_id: str) -> DsarRecordRef:
activities = []
if row.created_by_account_id == account_id:
activities.append("created_template_revision")
if row.published_by_account_id == account_id:
activities.append("published_template_revision")
return _record(
resource_type="template_revision_actor_attribution",
resource_id=row.id,
title="Template-revision actor attribution",
data={
"template_id": row.template_id,
"revision_id": row.id,
"revision": row.revision,
"template_type": row.template_type,
"locale": row.locale,
"activities": activities,
"created_at": _iso(row.created_at),
"published_at": _iso(row.published_at),
},
observed_at=row.published_at or row.created_at,
)
def _render_record(row: TemplateRender) -> DsarRecordRef:
return _record(
resource_type="template_render_actor_attribution",
resource_id=row.id,
title="Template-render actor attribution",
data={
"template_id": row.template_id,
"revision_id": row.revision_id,
"render_id": row.id,
"revision": row.revision_number,
"mode": row.mode,
"usage": row.usage,
"output_format": row.output_format,
"content_type": row.content_type,
"item_count": row.item_count,
"page_count": row.page_count,
"output_size_bytes": row.output_size_bytes,
"activity": "rendered_template",
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
)
def _record(
*,
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="templates",
module_id="templates",
resource_type=resource_type,
resource_id=resource_id,
category="template_governance_attribution",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Template definition, publication, and render attribution is retained "
"with immutable lifecycle evidence."
),
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Templates DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Templates DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"template_definition_actor_attribution",
"template_revision_actor_attribution",
"template_render_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "templates" or record.module_id != "templates":
raise ValueError("Templates DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Templates DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "templates" or action.module_id != "templates":
raise ValueError("Templates DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("templates:retain:"):
raise ValueError("Templates DSAR action identity is invalid.")
__all__ = ["TEMPLATES_DSAR_CAPABILITY", "TemplatesDsarProvider"]
@@ -0,0 +1,58 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'templates.data-subject-requests': {'consequence_classes': {'exclude_template_payloads': 'Gibt '
'keinen '
'Template-Inhalt '
'zurück '
'oder '
'rendert '
'Inputs '
'und '
'Outputs.',
'export_template_attribution': 'Returns '
'minimiert '
'Autor, '
'Publikation '
'und '
'Render-Aktivität.'}},
'templates.reference.fields-and-consequences': {'consequence_classes': {'delete_template': 'Verhindert '
'zukünftige '
'Auswahl, '
'ohne '
'beibehaltene '
'Revisionen '
'neu '
'zu '
'schreiben '
'oder '
'Nachweise '
'zu '
'erbringen.',
'publish_revision': 'Macht '
'die '
'ausgewählte '
'unveränderliche '
'Revision '
'für '
'die '
'Endverbraucherproduktion '
'geeignet.',
'render_final': 'Erstellt '
'gespeicherte '
'Render-Nachweise '
'und kann '
'ein '
'Artefakt '
'durch '
'Dateien '
'beibehalten.',
'save_revision': 'Erstellt '
'eine '
'neue '
'unveränderliche '
'Template-Revision.'}}}
+279 -21
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_templates.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
@@ -8,6 +11,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -18,21 +23,27 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
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
from govoplan_templates.backend.dsar_provider import (
TEMPLATES_DSAR_CAPABILITY,
TemplatesDsarProvider,
)
MODULE_ID = "templates"
MODULE_NAME = "Templates"
MODULE_VERSION = "0.1.18"
MODULE_VERSION = "0.1.21"
READ_SCOPE = "templates:template:read"
WRITE_SCOPE = "templates:template:write"
@@ -56,11 +67,29 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
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."),
_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 = (
@@ -85,7 +114,25 @@ DOCUMENTATION = (
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
conditions=(
DocumentationCondition(
required_modules=("templates",),
any_scopes=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE),
),
),
translations={
"de": {
"title": "Vorlagenbibliothek verwalten",
"summary": "Versionierte Vorlagen mit ausdrücklichen Verwendungen und erforderlichen Datenfeldern erstellen.",
"body": (
"Vorlagen sind wiederverwendbare, bereichsgebundene Definitionen. Jede Bearbeitung erzeugt eine unveränderliche Revision. "
"Veröffentlichen Sie die Revision, die Verbraucher für endgültige Ausgaben verwenden dürfen. Vor dem Rendern erläutert eine "
"Kompatibilitätsprüfung fehlende Felder, nicht unterstützte Verwendungen und nicht verfügbare Ausgabeformate."
),
}
},
metadata={
"kind": "workflow",
"seed": True,
"help_contexts": [
"templates.page",
@@ -95,6 +142,43 @@ DOCUMENTATION = (
],
},
),
DocumentationTopic(
id="templates.reusable-content",
title="Reusable content fragments and campaign parts",
summary="Create scoped, versioned content that authorized consumers can insert without copying a private library.",
body=(
"Content fragments retain text and HTML variants, usage constraints, locale, scope, revision, and publication state in Templates. "
"Campaign can load a fragment or complete email part through the optional content-library capability. Saving from Campaign creates "
"a draft with its declared required-field contract and never publishes it automatically. Consumers show missing required fields before "
"applying content. Inserting content changes only the current Campaign draft; existing Campaign versions "
"and Template revisions remain unchanged."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "campaign_author"),
related_modules=("campaigns",),
translations={
"de": {
"title": "Wiederverwendbare Inhaltsbausteine und Kampagnenteile",
"summary": "Bereichsbezogene, versionierte Inhalte erstellen und in berechtigten Modulen verwenden.",
"body": (
"Inhaltsbausteine speichern Text- und HTML-Fassungen, Verwendungszwecke, Sprache, Geltungsbereich, Revision und "
"Veröffentlichungsstatus in Templates. Campaign kann Bausteine oder vollständige E-Mail-Teile über die optionale "
"Inhaltsbibliothek laden. Das Speichern aus Campaign legt einen Entwurf mit dem deklarierten Pflichtfeldvertrag an und "
"veröffentlicht ihn niemals automatisch. Fehlende Pflichtfelder werden vor dem Anwenden angezeigt. "
"Das Einfügen ändert nur den aktuellen Kampagnenentwurf; bestehende Kampagnenversionen und Template-Revisionen bleiben unverändert."
),
}
},
metadata={
"seed": True,
"help_contexts": [
"templates.field.type",
"templates.field.usages",
"campaign.template.content-library",
],
},
),
DocumentationTopic(
id="templates.printable-output",
title="Printable template output",
@@ -109,6 +193,18 @@ DOCUMENTATION = (
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("files", "dist_lists", "campaigns", "audit"),
translations={
"de": {
"title": "Druckfähige Vorlagenausgabe",
"summary": "Etiketten, Umschläge, Briefe und Listenlayouts aus eingefrorenen Eingabe-Snapshots rendern.",
"body": (
"Eine Vorschau darf eine Entwurfsrevision verwenden. Endgültige Ausgabe verlangt eine veröffentlichte Revision und einen "
"Idempotenzschlüssel. Ergebnisse legen Vorlagenhash, Eingabehash, Renderer-Version, Element-/Seitenanzahl, Diagnosen und "
"Ausgabe-Digest fest. Files speichert Artefakte, wenn die Fähigkeit verfügbar und berechtigt ist; andernfalls stellt Templates "
"einen begrenzten Download bereit. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
),
}
},
metadata={
"seed": True,
"help_contexts": [
@@ -136,7 +232,26 @@ DOCUMENTATION = (
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("files", "dist_lists", "campaigns", "audit", "policy"),
translations={
"de": {
"title": "Vorlagenfelder und Folgen des Lebenszyklus",
"summary": (
"Semantik von Geltungsbereich, Verwendung, Datenvertrag, Veröffentlichung, Rendering und Löschung wiederverwendbarer Vorlagen."
),
"body": (
"Die Sichtbarkeit bestimmt, in welchem Mandanten-, Gruppen- oder Benutzerbereich eine Vorlage auffindbar ist; geerbte "
"Vorlagen können schreibgeschützt sein. Verwendungen sind Fähigkeitskontexte, die begrenzen, wo eine Vorlage ausgewählt werden "
"darf. Pflichtfelder bilden den Kompatibilitätsvertrag, der vor dem Rendern gegen bereitgestellte Daten geprüft wird. Speichern "
"erzeugt eine neue unveränderliche Revision. Veröffentlichen markiert eine Revision für endgültige Ausgabe, ohne ältere "
"Revisionen oder Nachweise umzuschreiben. Die Vorschau validiert und rendert begrenzte Beispielausgabe; endgültiges Rendering "
"verlangt die veröffentlichte Revision und zeichnet Vorlagen-, Eingabe- und Ausgabehash sowie Renderer-Nachweise auf. Files "
"darf das Artefakt aufbewahren, wenn die optionale Fähigkeit verfügbar ist. Löschen entfernt die Vorlage aus zukünftiger "
"Auswahl, schreibt aber aufbewahrte Rendering-Nachweise nicht um."
),
}
},
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"templates.field.type",
@@ -176,15 +291,29 @@ def _renderer(context: ModuleContext):
return renderer_capability(context)
def _content_library(context: ModuleContext):
from govoplan_templates.backend.capabilities import content_library_capability
return content_library_capability(context)
def _dsar_provider(_context: ModuleContext) -> TemplatesDsarProvider:
return TemplatesDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"templates": session.query(template_models.TemplateDefinition).filter(
"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(
)
.count(),
"template_renders": session.query(template_models.TemplateRender)
.filter(
template_models.TemplateRender.tenant_id == tenant_id,
).count(),
)
.count(),
}
@@ -196,8 +325,17 @@ manifest = ModuleManifest(
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),
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_CATALOG, version=MODULE_VERSION
),
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION
),
ModuleInterfaceProvider(name=TEMPLATES_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -214,7 +352,13 @@ manifest = ModuleManifest(
path="/templates",
label=MODULE_NAME,
icon="layout-template",
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
@@ -225,7 +369,13 @@ manifest = ModuleManifest(
FrontendRoute(
path="/templates",
component="TemplatesPage",
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
@@ -234,21 +384,74 @@ manifest = ModuleManifest(
path="/templates",
label=MODULE_NAME,
icon="layout-template",
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
required_any=(
READ_SCOPE,
WRITE_SCOPE,
PUBLISH_SCOPE,
RENDER_SCOPE,
ADMIN_SCOPE,
),
order=75,
),
),
product_areas=(
ProductAreaContribution(
id="records-documents",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.records_documents",
icon="folder",
description="i18n:govoplan-core.product_area.records_documents_description",
surface_ids=("templates.nav.templates", "templates.route.templates"),
order=30,
),
),
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),
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_CONTENT_LIBRARY: _content_library,
CAPABILITY_TEMPLATE_RENDERER: _renderer,
TEMPLATES_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
TEMPLATES_DSAR_CAPABILITY: CapabilityDocumentation(
label="Templates data-subject request provider",
summary=(
"Exports minimized template-author and render attribution without "
"template, input, or output payloads."
),
contract_version="0.1.0",
),
},
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
@@ -275,7 +478,53 @@ manifest = ModuleManifest(
label=MODULE_NAME,
),
),
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="templates.data-subject-requests",
title="Template data-subject requests",
summary=(
"Export template-author and render activity without content or supplied data."
),
body=(
"Templates correlates only an exact tenant account identifier and can "
"narrow an already verified search to one template, revision, or render. "
"It returns minimized definition, publication, and render lifecycle "
"metadata. Template text and HTML, required fields, layouts, metadata, "
"render input snapshots, filenames, diagnostics, artifact references, "
"output bytes, hashes, and idempotency keys are excluded. Rendered "
"business data belongs to the supplying module and is not inferred from "
"opaque Template payloads. Attribution remains retained with immutable "
"definition and render evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "files", "campaigns", "audit"),
order=90,
translations={
"de": {
"title": "Betroffenenanfragen für Vorlagen",
"summary": "Aktivität von Vorlagenautoren und Renderläufen ohne Inhalt oder bereitgestellte Daten exportieren.",
"body": (
"Templates gleicht nur eine exakte mandantenbezogene Kontokennung ab und kann eine bereits verifizierte Suche auf eine "
"Vorlage, Revision oder einen Renderlauf begrenzen. Ausgegeben werden minimierte Lebenszyklusmetadaten für Definition, "
"Veröffentlichung und Rendering. Vorlagentext und -HTML, Pflichtfelder, Layouts, Metadaten, Eingabe-Snapshots, Dateinamen, "
"Diagnosen, Artefaktverweise, Ausgabebytes, Hashes und Idempotenzschlüssel sind ausgeschlossen. Gerenderte Fachdaten gehören "
"dem bereitstellenden Modul und werden nicht aus undurchsichtigen Vorlagennutzdaten abgeleitet. Die Zuordnung bleibt mit "
"unveränderlichen Definitions- und Rendering-Nachweisen erhalten."
),
}
},
metadata={
"help_contexts": ["templates.page", "privacy.data-subject-requests"],
"consequence_classes": {
"export_template_attribution": "Returns minimized author, publication, and render activity.",
"exclude_template_payloads": "Does not return template content or render inputs and outputs.",
},
},
),
*DOCUMENTATION,
),
architecture=declared_module_architecture(
layer="content_records_evidence",
kind="domain",
@@ -286,7 +535,11 @@ manifest = ModuleManifest(
"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"),
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",),
@@ -295,5 +548,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -14,6 +14,7 @@ TemplateType = Literal[
"form_letter",
"list_layout",
"email",
"content_fragment",
"generic",
]
OutputFormat = Literal["html", "text"]
@@ -444,6 +444,10 @@ def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
output_profiles=tuple(
TemplateOutputProfile(**item) for item in revision.output_profiles
),
content_text=revision.content_text,
content_html=revision.content_html,
layout=dict(revision.layout or {}),
metadata=dict(revision.metadata_ or {}),
published_at=revision.published_at,
provenance={
"module": "templates",
@@ -502,6 +506,8 @@ def _create_revision(
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
if template_type == "content_fragment":
return []
media = "A4"
if template_type == "envelope":
media = "DL"
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
TemplateRevision,
)
from govoplan_templates.backend.dsar_provider import (
TEMPLATES_DSAR_CAPABILITY,
TemplatesDsarProvider,
)
from govoplan_templates.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 14, 0, tzinfo=UTC)
class TemplatesDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = TemplatesDsarProvider()
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add(
TemplateDefinition(
id="template-1",
tenant_id="tenant-1",
scope_type="tenant",
name="Sensitive template title do not export",
slug="secret-slug-do-not-export",
description="description-do-not-export",
template_type="letter",
status="published",
current_revision_id="revision-1",
current_revision=1,
published_revision_id="revision-1",
resource_revision=2,
created_by_account_id="account-1",
updated_by_account_id="account-1",
metadata_={"secret": "definition-metadata-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
)
self.session.add(
TemplateRevision(
id="revision-1",
tenant_id="tenant-1",
template_id="template-1",
revision=1,
definition_hash="definition-hash-do-not-export",
template_type="letter",
usages=["campaign"],
locale="de",
required_fields=[{"secret": "required-field-do-not-export"}],
output_profiles=[{"secret": "output-profile-do-not-export"}],
content_text="template-text-do-not-export",
content_html="template-html-do-not-export",
layout={"secret": "layout-do-not-export"},
metadata_={"secret": "revision-metadata-do-not-export"},
created_by_account_id="account-1",
published_at=NOW,
published_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
)
self.session.add(
TemplateRender(
id="render-1",
tenant_id="tenant-1",
template_id="template-1",
revision_id="revision-1",
revision_number=1,
mode="final",
usage="campaign",
output_format="html",
content_type="text/html",
filename="personal-filename-do-not-export.html",
idempotency_key="render-idempotency-do-not-export",
template_hash="template-hash-do-not-export",
input_hash="input-hash-do-not-export",
renderer_version="renderer-v1",
output_sha256="output-hash-do-not-export",
output_size_bytes=123,
item_count=2,
page_count=1,
diagnostics=[{"secret": "diagnostic-do-not-export"}],
input_snapshot={"person": "input-person-do-not-export"},
artifact_ref={"secret": "artifact-ref-do-not-export"},
payload=b"output-payload-do-not-export",
created_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
)
def test_search_is_minimized_and_narrowable(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(3, len(records))
exported = json.dumps([record.to_dict() for record in records])
for excluded in (
"Sensitive template title do not export",
"secret-slug-do-not-export",
"definition-metadata-do-not-export",
"definition-hash-do-not-export",
"required-field-do-not-export",
"template-text-do-not-export",
"template-html-do-not-export",
"personal-filename-do-not-export",
"render-idempotency-do-not-export",
"template-hash-do-not-export",
"input-person-do-not-export",
"artifact-ref-do-not-export",
"output-payload-do-not-export",
):
self.assertNotIn(excluded, exported)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"templates.render": "render-1"},
),
)
self.assertEqual(
{"template_render_actor_attribution"},
{record.resource_type for record in narrowed},
)
def test_account_is_required_and_records_are_retained(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="author@example.test"),
),
)
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(TEMPLATES_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"templates.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()
@@ -6,6 +6,14 @@ from govoplan_templates.backend.manifest import manifest
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
@@ -27,10 +35,12 @@ class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
reference = topics["templates.reference.fields-and-consequences"]
self.assertIn("templates.state.read-only", library.metadata["help_contexts"])
self.assertEqual("workflow", library.metadata["kind"])
self.assertIn("templates.action.render-final", output.metadata["help_contexts"])
self.assertIn("templates.field.usages", reference.metadata["help_contexts"])
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
self.assertIn("delete_template", reference.metadata["consequence_classes"])
self.assertEqual("reference", reference.metadata["kind"])
if __name__ == "__main__":
+50 -1
View File
@@ -2,20 +2,27 @@ from __future__ import annotations
import hashlib
import unittest
from unittest.mock import patch
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.files import ManagedArtifactRef
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCompatibilityError,
TemplateContentDraftRequest,
TemplateFieldRequirement,
TemplateRenderError,
TemplateRenderRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_templates.backend.capabilities import SqlTemplateCatalog
from govoplan_templates.backend.capabilities import (
SqlTemplateCatalog,
SqlTemplateContentLibrary,
)
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
@@ -158,10 +165,48 @@ class TemplateServiceTests(unittest.TestCase):
self.assertEqual(1, len(refs))
self.assertEqual("serial_letter", refs[0].template_type)
self.assertEqual(("campaign.postal",), refs[0].revision.usages)
self.assertIn("Dear", refs[0].revision.content_html)
self.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
self.assertNotEqual(first.definition_hash, second.definition_hash)
self.assertEqual(2, second.revision)
def test_content_library_creates_unpublished_provider_owned_draft(self) -> None:
with self.database.session() as session, patch(
"govoplan_templates.backend.capabilities.audit_from_principal"
), patch("govoplan_templates.backend.capabilities.emit_platform_event"):
result = SqlTemplateContentLibrary().create_content_draft(
session,
principal(),
request=TemplateContentDraftRequest(
name="Closing paragraph",
template_type="content_fragment",
usages=("campaign.content",),
locale="de",
content_text="Mit freundlichen Grüßen",
required_fields=(
TemplateFieldRequirement(
path="local.display_name",
label="Display name",
),
),
metadata={"campaign_targets": ["text"]},
),
)
session.commit()
self.assertEqual("draft", result.status)
self.assertEqual("content_fragment", result.template_type)
self.assertEqual("Mit freundlichen Grüßen", result.revision.content_text)
self.assertEqual(
"local.display_name",
result.revision.required_fields[0].path,
)
self.assertEqual(["text"], result.revision.metadata["campaign_targets"])
self.assertEqual(
"templates.content_library",
result.revision.metadata["created_through"],
)
def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
frozen = (
{"name": "Ada", "postal": {"address": "Street 1"}},
@@ -322,6 +367,10 @@ class TemplateManifestTests(unittest.TestCase):
self.assertFalse(manifest.dependencies)
self.assertIn("files", manifest.optional_dependencies)
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories)
self.assertIn(
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
manifest.capability_factories,
)
self.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/templates-webui",
"version": "0.1.18",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1
View File
@@ -13,6 +13,7 @@ export type TemplateType =
| "form_letter"
| "list_layout"
| "email"
| "content_fragment"
| "generic";
export type TemplateFieldType =
+94 -63
View File
@@ -3,27 +3,35 @@ import {
Eye,
FileCheck2,
Plus,
RefreshCw,
Save,
Send,
Trash2,
X
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
import { DialogSection, ActionToolbar,
ApiError,
ActionBlockerHint,
Button,
ConfirmDialog,
ContentSection,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
FormField,
IconButton,
LoadingFrame,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime,
hasScope,
useUnsavedChanges,
@@ -70,6 +78,7 @@ const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
{ value: "form_letter", label: "Form letter" },
{ value: "list_layout", label: "List layout" },
{ value: "email", label: "Email" },
{ value: "content_fragment", label: "Content fragment" },
{ value: "generic", label: "Generic" }
];
@@ -292,20 +301,31 @@ export default function TemplatesPage({ settings, auth }: Props) {
};
return (
<main className="templates-page">
<div className="templates-shell">
<aside className="templates-sidebar">
<div className="templates-sidebar-toolbar">
<strong>Template library</strong>
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
</div>
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
<div className="templates-list">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Template library"
contentLabel="Template workspace"
contentClassName="templates-workspace"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
contextActions={<strong>Template library</strong>}
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
/>
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
<SelectionList variant="navigation" label="Templates">
{visibleItems.map((item) => (
<button
<SelectionListItem
key={item.id}
type="button"
className={item.id === selectedId ? "is-selected" : ""}
selected={item.id === selectedId}
onClick={() => {
if (item.id === selectedId) return;
requestDiscard(() => {
@@ -314,34 +334,42 @@ export default function TemplatesPage({ settings, auth }: Props) {
});
}}
>
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
<SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
<StatusBadge status={item.status} label={item.status} />
</button>
</SelectionListItem>
))}
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
</div>
</aside>
<section className="templates-workspace">
<header className="templates-workspace-toolbar">
<span className="templates-current-title">
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
</SelectionList>
</>}
>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state={busy ? "saving" : dirty ? "dirty" : "clean"}
className="templates-workspace-toolbar"
contextActions={<span className="templates-current-title">
<strong>{selected?.name ?? "Select a template"}</strong>
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
</span>
<div className="templates-toolbar-actions">
<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} disabled={loading || busy} disabledReason={loading ? TEMPLATES_I18N.loading : busy ? TEMPLATES_I18N.busy : undefined} onClick={() => requestDiscard(() => void reload(selectedId))} />
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : !dirty ? TEMPLATES_I18N.noChanges : undefined} onClick={() => void save()}><Save size={16} /> Save revision</Button>
<Button disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />
</span>}
helpAction={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}
primaryActions={<>
<Button helpContextId="templates.action.publish" helpModuleId="templates" disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
<SegmentedControl
value={view}
onChange={setView}
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
ariaLabel="Template workspace"
/>
</div>
</header>
</>}
destructiveActions={<IconButton label="Delete template" helpContextId="templates.action.delete" helpModuleId="templates" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />}
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
saveAction={{
label: <><Save size={16} /> Save revision</>,
disabled: !selected || readOnly || busy,
disabledReason: busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined,
onClick: () => void save()
}}
/>
<div className="templates-alerts">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
@@ -362,7 +390,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
<LoadingFrame loading={loading} label="Loading templates">
<div className="templates-content">
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <>
{!selected ? <StatePanel size="fill" title="Templates" description="Create or select a reusable template." /> : view === "definition" ? <>
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
</> : <>
@@ -387,25 +415,24 @@ export default function TemplatesPage({ settings, auth }: Props) {
</>}
</div>
</LoadingFrame>
</section>
</div>
</WorkspaceLayout>
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
<div className="templates-dialog-form">
<DialogSection className="templates-dialog-form">
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
</div>
</DialogSection>
</Dialog>
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
</main>
</WorkspaceFrame>
);
}
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
return <ContentSection className="templates-history">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></ActionToolbar>
<div className="templates-history-list">
{revisions.map((revision) => <div key={revision.id}>
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
@@ -414,22 +441,22 @@ function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }:
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
</span>
</div>)}
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>}
{!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
</div>
</section>;
</ContentSection>;
}
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
return <ContentSection className="templates-history">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></ActionToolbar>
<div className="templates-history-list">
{renders.map((item) => <div key={item.render_id}>
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
</div>)}
{!renders.length && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
{!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
</div>
</section>;
</ContentSection>;
}
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
@@ -452,8 +479,8 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
</div>
<section className="templates-section">
<div className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></div>
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></ActionToolbar>
<div className="templates-fields-table">
{draft.required_fields.map((field, index) => (
<div className="templates-field-row" key={`${index}:${field.path}`}>
@@ -464,12 +491,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
</div>
))}
{!draft.required_fields.length && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
{!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
</div>
</section>
</ContentSection>
<section className="templates-section">
<div className="templates-section-heading"><strong>Page and media</strong></div>
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
<div className="templates-layout-fields">
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
@@ -479,12 +506,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
</>}
</div>
</section>
</ContentSection>
<section className="templates-section templates-body-section">
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
<ContentSection className="templates-body-section">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></ActionToolbar>
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
</section>
</ContentSection>
</div>
);
}
@@ -508,8 +535,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
}) {
return (
<div className="templates-preview">
<section className="templates-section">
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
<ContentSection>
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></ActionToolbar>
<div className="templates-preview-controls">
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
@@ -520,7 +547,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
</div>
</section>
</ContentSection>
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
{compatibility.compatible
@@ -528,8 +555,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
</DismissibleAlert>}
{render && <section className="templates-section templates-render-result">
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
{render && <ContentSection className="templates-render-result">
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></ActionToolbar>
<dl>
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
@@ -539,7 +566,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
</dl>
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
</section>}
</ContentSection>}
</div>
);
}
@@ -551,7 +578,11 @@ function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePaylo
scope_type: "tenant",
scope_id: null,
template_type: templateType,
usages: [templateType === "email" ? "campaign.email" : "campaign.postal"],
usages: templateType === "email"
? ["campaign.email", "campaign.content"]
: templateType === "content_fragment"
? ["campaign.content"]
: ["campaign.postal"],
locale: "en",
required_fields: [],
output_profiles: [],
+3 -59
View File
@@ -1,71 +1,19 @@
.templates-page {
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
.templates-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
.templates-workspace-toolbar { min-width: 0; }
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.templates-current-title { min-width: 0; flex: 1 1 auto; }
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
.templates-alerts:empty { display: none; }
.templates-alerts .alert { margin: 10px 0 0; }
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
.templates-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
.templates-section-heading small { color: var(--muted); font-weight: 400; }
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-fields-table { overflow: auto; padding: 8px; }
@@ -93,8 +41,7 @@
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
@media (max-width: 980px) {
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
@media (max-width: 1100px) {
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -102,11 +49,8 @@
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
@media (max-width: 760px) {
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
.templates-workspace { overflow: visible; }
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
.templates-toolbar-actions { flex-wrap: wrap; }
.templates-content { height: auto; overflow: visible; }