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
import fnmatch
import re
import time
from dataclasses import dataclass, field
from enum import StrEnum
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
assert_logical_relative_path,
is_managed_source,
)
from govoplan_campaign.backend.template_rendering import render_template
class AttachmentScope(StrEnum):
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
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:
rendered = _render_template(config.base_dir, values).strip()
rendered = render_template(config.base_dir, values, keep_missing=True).strip()
return rendered or "."
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is 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"
@@ -444,7 +409,7 @@ def _resolve_one_config(
match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment:
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(
campaign_file=campaign_file,
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.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 (
CampaignBuildReport,
@@ -47,28 +51,6 @@ from .models import (
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)
class BuiltMessage:
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)
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:
if recipient is 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)
}