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 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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user