feat(campaign): complete reusable template workflow

This commit is contained in:
2026-08-19 21:56:08 +02:00
parent 14e94873a9
commit b6af7665f4
11 changed files with 294 additions and 14 deletions
@@ -199,7 +199,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
topic_id="campaigns.workflow.reuse-content-library",
title="Reuse Campaign content through Templates",
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body in the editable Campaign draft. Saving from Campaign creates a personal or tenant Templates draft and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
@@ -217,7 +217,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
steps=(
"Open Template and choose Load from library to search content visible in the active Templates scope.",
"Insert a fragment into its declared subject, text, or HTML target, or explicitly apply a complete Campaign part.",
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
"Review placeholders and save the Campaign draft normally.",
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
"Open Templates to review, revise, and publish shared content.",
@@ -230,7 +230,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"de": {
"title": "Kampagneninhalte über Templates wiederverwenden",
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext im bearbeitbaren Kampagnenentwurf. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
}
},
),
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import dataclasses
import re
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -43,7 +44,11 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
from govoplan_core.core.templates import (
TemplateContentDraftRequest,
TemplateFieldRequirement,
TemplateRef,
)
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
encode_sequence_watermark,
@@ -80,6 +85,7 @@ from govoplan_campaign.backend.integrations import (
postbox_integration,
templates_integration,
)
from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders
from govoplan_core.db.session import get_session
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
@@ -975,9 +981,45 @@ def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
"text": revision.content_text if revision else None,
"html": revision.content_html if revision else None,
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
"required_fields": [
dataclasses.asdict(field) for field in revision.required_fields
] if revision else [],
}
def _campaign_content_required_fields(
payload: CampaignContentLibrarySaveRequest,
) -> tuple[TemplateFieldRequirement, ...]:
if payload.kind == "fragment":
values = (
payload.subject if payload.target == "subject"
else payload.html if payload.target == "html"
else payload.text,
)
else:
values = (
payload.subject,
payload.text if payload.body_mode != "html" else None,
payload.html if payload.body_mode != "text" else None,
)
paths = {
path
for value in values
for key in find_unresolved_placeholders(value)
if (path := _campaign_content_requirement_path(key)) is not None
}
return tuple(
TemplateFieldRequirement(path=path, label=path)
for path in sorted(paths)
)
def _campaign_content_requirement_path(key: str) -> str | None:
path = key.replace("local::", "local.", 1).replace("global::", "global.", 1)
path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path)
return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None
@router.get("/{campaign_id}/content-library")
def campaign_content_library(
campaign_id: str,
@@ -1081,6 +1123,7 @@ def save_campaign_content_library_item(
content_text=content_text,
content_html=content_html,
locale=payload.locale,
required_fields=_campaign_content_required_fields(payload),
scope_type=("user" if payload.visibility == "personal" else "tenant"),
scope_id=(
principal.account_id if payload.visibility == "personal" else None