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 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,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:
return SqlTemplateCatalog()
@@ -116,6 +194,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 +219,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 +237,8 @@ def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -
__all__ = [
"SqlTemplateCatalog",
"SqlTemplateContentLibrary",
"catalog_capability",
"content_library_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.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
)
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(
id="templates.printable-output",
title="Printable template output",
@@ -176,6 +212,12 @@ 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 _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"templates": session.query(template_models.TemplateDefinition).filter(
@@ -197,6 +239,10 @@ manifest = ModuleManifest(
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
provides_interfaces=(
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),
),
requires_interfaces=(
@@ -248,6 +294,7 @@ manifest = ModuleManifest(
route_factory=_router,
capability_factories={
CAPABILITY_TEMPLATE_CATALOG: _catalog,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY: _content_library,
CAPABILITY_TEMPLATE_RENDERER: _renderer,
},
tenant_summary_providers=(_tenant_summary,),
@@ -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"