feat: add reusable content fragments
This commit is contained in:
@@ -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"
|
||||
|
||||
+39
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TemplateType =
|
||||
| "form_letter"
|
||||
| "list_layout"
|
||||
| "email"
|
||||
| "content_fragment"
|
||||
| "generic";
|
||||
|
||||
export type TemplateFieldType =
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user