diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index aebb628..0765251 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -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.", } }, ), diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index b0a6d2a..a90dee2 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -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 diff --git a/tests/test_campaign_content_library.py b/tests/test_campaign_content_library.py index 2f9eef6..20b2033 100644 --- a/tests/test_campaign_content_library.py +++ b/tests/test_campaign_content_library.py @@ -3,7 +3,10 @@ from __future__ import annotations from datetime import UTC, datetime from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration -from govoplan_campaign.backend.routes.campaigns import _campaign_content_library_item +from govoplan_campaign.backend.routes.campaigns import ( + _campaign_content_library_item, + _campaign_content_required_fields, +) from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest from govoplan_core.core.templates import ( TemplateContentDraftRequest, @@ -109,6 +112,26 @@ def test_campaign_content_payload_keeps_revision_and_declared_target() -> None: assert payload["text"] == "Mit freundlichen Grussen" assert payload["published"] is True assert payload["revision"] == 3 + assert payload["required_fields"] == [] + + +def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None: + payload = CampaignContentLibrarySaveRequest( + name="Greeting", + kind="campaign_part", + subject="Hello {{ local:display_name }}", + text="Reference ${global.case_reference}; hello {{local::display_name}}", + html="
{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}
", + ) + + requirements = _campaign_content_required_fields(payload) + + assert [item.path for item in requirements] == [ + "global.case_reference", + "local.display_name", + "local.to.2.email", + "salutation", + ] def test_content_save_request_rejects_empty_selected_fragment() -> None: diff --git a/webui/package.json b/webui/package.json index 89a1408..fd7a2f7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -25,7 +25,7 @@ }, "scripts": { "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", - "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js", + "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js && node tests/content-library-ui-structure.test.mjs", "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", "test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs", "test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js", diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 08b1705..1bcb53a 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -513,6 +513,13 @@ export type CampaignContentLibraryItem = { text?: string | null; html?: string | null; body_mode: "text" | "html" | "both"; + required_fields: Array<{ + path: string; + value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "object" | "array"; + label?: string | null; + required: boolean; + description?: string | null; + }>; }; export type CampaignContentLibraryResponse = { diff --git a/webui/src/features/campaigns/TemplateDataPage.tsx b/webui/src/features/campaigns/TemplateDataPage.tsx index 613f038..d5df91b 100644 --- a/webui/src/features/campaigns/TemplateDataPage.tsx +++ b/webui/src/features/campaigns/TemplateDataPage.tsx @@ -13,7 +13,7 @@ import { } from "../../api/campaigns"; import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; -import { Dialog } from "@govoplan/core-webui"; +import { ConfirmDialog, Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui"; @@ -31,11 +31,19 @@ import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; +import { campaignContentFieldType, checkContentLibraryCompatibility, type ContentLibraryCompatibility } from "./utils/contentLibraryCompatibility"; type TemplateBodyMode = "text" | "html" | "both"; type BodyEditorMode = "text" | "html"; type EditorTarget = "subject" | "text" | "html"; type ContentLibrarySaveKind = "fragment" | "campaign_part"; +type PendingContentApply = { + item: CampaignContentLibraryItem; + target?: CampaignContentLibraryTarget; + value?: string; + overwrites: boolean; + compatibility: ContentLibraryCompatibility; +}; export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); @@ -64,6 +72,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap const [contentSaveTarget, setContentSaveTarget] = useStateNo reusable Campaign content matches this search.
)} - {contentLibrary?.items.map((item) => ( -{item.description}
} {item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"} + {!compatibility.compatible && ( +