Deduplicate campaign template rendering

This commit is contained in:
2026-07-21 13:14:32 +02:00
parent 3c305753d6
commit 641bead0d8
4 changed files with 164 additions and 84 deletions

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import fnmatch import fnmatch
import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
assert_logical_relative_path, assert_logical_relative_path,
is_managed_source, is_managed_source,
) )
from govoplan_campaign.backend.template_rendering import render_template
class AttachmentScope(StrEnum): class AttachmentScope(StrEnum):
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve() return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str: def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip() rendered = render_template(config.base_dir, values, keep_missing=True).strip()
return rendered or "." return rendered or "."
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None: def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is None: if archive is None:
return None return None
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip" rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip" return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
@@ -444,7 +409,7 @@ def _resolve_one_config(
match_index: AttachmentMatchIndex | None = None, match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment: ) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values) rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values) rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
directory, selected_base_path = _resolve_attachment_directory( directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file, campaign_file=campaign_file,
campaign_config=campaign_config, campaign_config=campaign_config,

View File

@@ -36,6 +36,10 @@ from govoplan_campaign.backend.campaign.models import (
) )
from govoplan_campaign.backend.campaign.template_values import build_template_values from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive from govoplan_campaign.backend.services.zip_service import create_zip_archive
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
)
from .models import ( from .models import (
CampaignBuildReport, CampaignBuildReport,
@@ -47,28 +51,6 @@ from .models import (
MessageValidationStatus, MessageValidationStatus,
) )
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True) @dataclass(slots=True)
class BuiltMessage: class BuiltMessage:
draft: MessageDraft draft: MessageDraft
@@ -123,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
return path.read_text(encoding=encoding) return path.read_text(encoding=encoding)
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None: def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
if recipient is None: if recipient is None:
return None return None

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def render_template(
template: str,
values: Mapping[str, Any],
*,
keep_missing: bool = True,
) -> str:
def replace(match: re.Match[str]) -> str:
key = normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.attachments.resolver import resolve_entry_attachments
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders,
normalize_template_key,
render_template,
)
class CampaignTemplateRenderingTests(unittest.TestCase):
def test_template_key_aliases_have_shared_normalization(self) -> None:
cases = {
" name ": "name",
"fields.name": "name",
"local.name": "local::name",
"local:name": "local::name",
"local::name": "local::name",
"global.name": "global::name",
"global:name": "global::name",
"global::name": "global::name",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(normalize_template_key(raw), expected)
def test_rendering_preserves_both_syntaxes_none_and_missing_values(self) -> None:
values = {
"name": "Ada",
"local::reference": 42,
"global::empty": None,
}
template = "${fields.name}|{{ local.reference }}|${global:empty}|${missing}|{{ global.absent }}"
self.assertEqual(
render_template(template, values),
"Ada|42||${missing}|{{ global.absent }}",
)
self.assertEqual(render_template(template, values, keep_missing=False), "Ada|42|||")
def test_rendering_unescapes_literal_dollar_placeholders(self) -> None:
self.assertEqual(
render_template(r"\${literal\}|${known}", {"known": "resolved"}, keep_missing=False),
"${literal}|resolved",
)
def test_unresolved_placeholders_are_normalized_and_deduplicated(self) -> None:
unresolved = find_unresolved_placeholders(
r"${fields.missing} {{ local:name }} ${global.other} {{local::name}} \${escaped\}"
)
self.assertEqual(unresolved, {"missing", "local::name", "global::other"})
self.assertEqual(find_unresolved_placeholders(None), set())
def test_attachment_resolution_keeps_missing_placeholders(self) -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "template-parity", "name": "Template parity", "mode": "test"},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"base_path": ".",
"global": [
{
"base_dir": ".",
"file_filter": "${missing}.pdf",
"required": False,
"missing_behavior": "continue",
}
],
},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.test", "type": "to"}],
}
]
},
}
)
with tempfile.TemporaryDirectory() as temp_dir:
resolution = resolve_entry_attachments(
config=config,
campaign_file=Path(temp_dir) / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.attachments[0].file_filter, "${missing}.pdf")
if __name__ == "__main__":
unittest.main()