diff --git a/src/govoplan_templates/backend/capabilities.py b/src/govoplan_templates/backend/capabilities.py index ab13a7a..1cab570 100644 --- a/src/govoplan_templates/backend/capabilities.py +++ b/src/govoplan_templates/backend/capabilities.py @@ -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", ] diff --git a/src/govoplan_templates/backend/manifest.py b/src/govoplan_templates/backend/manifest.py index 140540b..9e9671f 100644 --- a/src/govoplan_templates/backend/manifest.py +++ b/src/govoplan_templates/backend/manifest.py @@ -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,), diff --git a/src/govoplan_templates/backend/schemas.py b/src/govoplan_templates/backend/schemas.py index 2bd26b4..81e1a3c 100644 --- a/src/govoplan_templates/backend/schemas.py +++ b/src/govoplan_templates/backend/schemas.py @@ -14,6 +14,7 @@ TemplateType = Literal[ "form_letter", "list_layout", "email", + "content_fragment", "generic", ] OutputFormat = Literal["html", "text"] diff --git a/src/govoplan_templates/backend/service.py b/src/govoplan_templates/backend/service.py index 32ee793..524b8d4 100644 --- a/src/govoplan_templates/backend/service.py +++ b/src/govoplan_templates/backend/service.py @@ -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" diff --git a/tests/test_templates.py b/tests/test_templates.py index 0a8b716..4c4da81 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -2,20 +2,26 @@ 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, 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 +164,38 @@ 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", + 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: frozen = ( {"name": "Ada", "postal": {"address": "Street 1"}}, @@ -322,6 +356,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) diff --git a/webui/src/api/templates.ts b/webui/src/api/templates.ts index b7b4f9c..b5e84fb 100644 --- a/webui/src/api/templates.ts +++ b/webui/src/api/templates.ts @@ -13,6 +13,7 @@ export type TemplateType = | "form_letter" | "list_layout" | "email" + | "content_fragment" | "generic"; export type TemplateFieldType = diff --git a/webui/src/features/templates/TemplatesPage.tsx b/webui/src/features/templates/TemplatesPage.tsx index 31cb4a2..b9ea2ed 100644 --- a/webui/src/features/templates/TemplatesPage.tsx +++ b/webui/src/features/templates/TemplatesPage.tsx @@ -70,6 +70,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" } ]; @@ -551,7 +552,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: [],