feat: add reusable content fragments

This commit is contained in:
2026-08-07 14:53:34 +02:00
parent 6f8a70f864
commit 21ed6270a3
7 changed files with 192 additions and 2 deletions
@@ -4,17 +4,30 @@ from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal 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.modules import ModuleContext
from govoplan_core.core.templates import ( from govoplan_core.core.templates import (
TemplateCatalogProvider, TemplateCatalogProvider,
TemplateCompatibility, TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef, TemplateRef,
) )
from govoplan_templates.backend.rendering import SqlTemplateRenderer from govoplan_templates.backend.rendering import SqlTemplateRenderer
from govoplan_templates.backend.schemas import TemplateCreateRequest
from govoplan_templates.backend.service import ( from govoplan_templates.backend.service import (
READ_SCOPE, READ_SCOPE,
WRITE_SCOPE,
compatibility, compatibility,
create_template,
get_template, get_template,
get_template_revision, get_template_revision,
list_templates, list_templates,
@@ -108,6 +121,71 @@ 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=[],
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 []),
"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: def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
return SqlTemplateCatalog() return SqlTemplateCatalog()
@@ -116,6 +194,10 @@ def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
return SqlTemplateRenderer(context.registry) return SqlTemplateRenderer(context.registry)
def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary:
return SqlTemplateContentLibrary()
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]: def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
if not isinstance(session, Session): if not isinstance(session, Session):
raise TypeError("Template catalogue access requires a SQLAlchemy session.") raise TypeError("Template catalogue access requires a SQLAlchemy session.")
@@ -137,6 +219,14 @@ def _require_read(principal: ApiPrincipal) -> None:
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.") 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: def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
if principal.has("templates:template:admin") or scope_type == "tenant": if principal.has("templates:template:admin") or scope_type == "tenant":
return False return False
@@ -147,6 +237,8 @@ def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -
__all__ = [ __all__ = [
"SqlTemplateCatalog", "SqlTemplateCatalog",
"SqlTemplateContentLibrary",
"catalog_capability", "catalog_capability",
"content_library_capability",
"renderer_capability", "renderer_capability",
] ]
@@ -23,6 +23,7 @@ from govoplan_core.core.modules import (
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.templates import ( from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER, CAPABILITY_TEMPLATE_RENDERER,
) )
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
@@ -95,6 +96,41 @@ 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 and never publishes it automatically. 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 an und veröffentlicht ihn niemals automatisch. "
"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( DocumentationTopic(
id="templates.printable-output", id="templates.printable-output",
title="Printable template output", title="Printable template output",
@@ -176,6 +212,12 @@ def _renderer(context: ModuleContext):
return renderer_capability(context) return renderer_capability(context)
def _content_library(context: ModuleContext):
from govoplan_templates.backend.capabilities import content_library_capability
return content_library_capability(context)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return { return {
"templates": session.query(template_models.TemplateDefinition).filter( "templates": session.query(template_models.TemplateDefinition).filter(
@@ -197,6 +239,10 @@ manifest = ModuleManifest(
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,), optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_TEMPLATE_CATALOG, 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=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION),
), ),
requires_interfaces=( requires_interfaces=(
@@ -248,6 +294,7 @@ manifest = ModuleManifest(
route_factory=_router, route_factory=_router,
capability_factories={ capability_factories={
CAPABILITY_TEMPLATE_CATALOG: _catalog, CAPABILITY_TEMPLATE_CATALOG: _catalog,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY: _content_library,
CAPABILITY_TEMPLATE_RENDERER: _renderer, CAPABILITY_TEMPLATE_RENDERER: _renderer,
}, },
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
@@ -14,6 +14,7 @@ TemplateType = Literal[
"form_letter", "form_letter",
"list_layout", "list_layout",
"email", "email",
"content_fragment",
"generic", "generic",
] ]
OutputFormat = Literal["html", "text"] OutputFormat = Literal["html", "text"]
@@ -444,6 +444,10 @@ def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
output_profiles=tuple( output_profiles=tuple(
TemplateOutputProfile(**item) for item in revision.output_profiles 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, published_at=revision.published_at,
provenance={ provenance={
"module": "templates", "module": "templates",
@@ -502,6 +506,8 @@ def _create_revision(
def _default_output_profiles(template_type: str) -> list[dict[str, object]]: def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
if template_type == "content_fragment":
return []
media = "A4" media = "A4"
if template_type == "envelope": if template_type == "envelope":
media = "DL" media = "DL"
+39 -1
View File
@@ -2,20 +2,26 @@ from __future__ import annotations
import hashlib import hashlib
import unittest import unittest
from unittest.mock import patch
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.files import ManagedArtifactRef from govoplan_core.core.files import ManagedArtifactRef
from govoplan_core.core.templates import ( from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER, CAPABILITY_TEMPLATE_RENDERER,
TemplateCompatibilityError, TemplateCompatibilityError,
TemplateContentDraftRequest,
TemplateRenderError, TemplateRenderError,
TemplateRenderRequest, TemplateRenderRequest,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database 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 ( from govoplan_templates.backend.db.models import (
TemplateDefinition, TemplateDefinition,
TemplateRender, TemplateRender,
@@ -158,10 +164,38 @@ class TemplateServiceTests(unittest.TestCase):
self.assertEqual(1, len(refs)) self.assertEqual(1, len(refs))
self.assertEqual("serial_letter", refs[0].template_type) self.assertEqual("serial_letter", refs[0].template_type)
self.assertEqual(("campaign.postal",), refs[0].revision.usages) 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.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
self.assertNotEqual(first.definition_hash, second.definition_hash) self.assertNotEqual(first.definition_hash, second.definition_hash)
self.assertEqual(2, second.revision) 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",
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(["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: def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
frozen = ( frozen = (
{"name": "Ada", "postal": {"address": "Street 1"}}, {"name": "Ada", "postal": {"address": "Street 1"}},
@@ -322,6 +356,10 @@ class TemplateManifestTests(unittest.TestCase):
self.assertFalse(manifest.dependencies) self.assertFalse(manifest.dependencies)
self.assertIn("files", manifest.optional_dependencies) self.assertIn("files", manifest.optional_dependencies)
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories) 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.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name) self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
+1
View File
@@ -13,6 +13,7 @@ export type TemplateType =
| "form_letter" | "form_letter"
| "list_layout" | "list_layout"
| "email" | "email"
| "content_fragment"
| "generic"; | "generic";
export type TemplateFieldType = export type TemplateFieldType =
@@ -70,6 +70,7 @@ const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
{ value: "form_letter", label: "Form letter" }, { value: "form_letter", label: "Form letter" },
{ value: "list_layout", label: "List layout" }, { value: "list_layout", label: "List layout" },
{ value: "email", label: "Email" }, { value: "email", label: "Email" },
{ value: "content_fragment", label: "Content fragment" },
{ value: "generic", label: "Generic" } { value: "generic", label: "Generic" }
]; ];
@@ -551,7 +552,11 @@ function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePaylo
scope_type: "tenant", scope_type: "tenant",
scope_id: null, scope_id: null,
template_type: templateType, 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", locale: "en",
required_fields: [], required_fields: [],
output_profiles: [], output_profiles: [],