Insert a working repeat block instead of its description

The placeholder chooser showed repeat blocks as "{{#claims}} … {{/claims}}" and
inserted exactly that on double-click -- the ellipsis is a label, so the saved
template rendered a literal "…" per claim instead of the claim.

Chooser rows are data now: each carries the text it reads as and, separately,
the snippet it inserts. A block contributes a complete, ready-to-edit block with
a sample line built from its own placeholders, and lands on a line of its own
when the cursor sits behind existing text.

Covered from both sides: the snippets are checked against the template validator
and renderer, and a UI test drives the real dialog -- insert, save, send -- and
asserts the mail carries actual claim lines. The Tk tests share one root (the
icon library binds its images to the first one) and skip without a display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-28 21:08:23 +02:00
co-authored by Claude Opus 5
parent 592c5482d6
commit d3dbb5e96d
4 changed files with 275 additions and 31 deletions
+65 -18
View File
@@ -26,6 +26,33 @@ class MailTemplate:
body: str
@dataclass(frozen=True, slots=True)
class MailTemplateBlock:
name: str
description: str
# Placeholder name -> short explanation, valid inside this block only.
placeholders: tuple[tuple[str, str], ...]
# The line the inserted block starts out with. It makes the inserted snippet a
# working block that renders real content -- the board edits this line rather
# than having to write the whole construct by hand.
sample: str
@property
def snippet(self) -> str:
return f"{{{{#{self.name}}}}}\n{self.sample}\n{{{{/{self.name}}}}}\n"
@dataclass(frozen=True, slots=True)
class PlaceholderEntry:
"""One row of the options dialog's placeholder chooser: what it reads as, and
what double-clicking it puts into the template."""
label: str
snippet: str
description: str
indented: bool = False
@dataclass(frozen=True, slots=True)
class MailTemplateSpec:
key: str
@@ -36,8 +63,7 @@ class MailTemplateSpec:
# dialog. The mail services must supply exactly these keys, so this doubles as
# the contract a stored template is validated against.
placeholders: tuple[tuple[str, str], ...]
# Repeat block name -> (explanation, item placeholders).
blocks: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = ()
blocks: tuple[MailTemplateBlock, ...] = ()
BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
@@ -99,10 +125,11 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
(
"claims",
"Wiederholt sich je ausgewählter Forderung",
CLAIM_ITEM_PLACEHOLDERS,
MailTemplateBlock(
name="claims",
description="Wiederholt sich je ausgewählter Forderung",
placeholders=CLAIM_ITEM_PLACEHOLDERS,
sample="{{claim.description}} (fällig {{claim.due_date}}): {{claim.balance}} Euro",
),
),
),
@@ -128,13 +155,14 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
(
"reminder.items",
"Wiederholt sich je Mahnposition",
(
MailTemplateBlock(
name="reminder.items",
description="Wiederholt sich je Mahnposition",
placeholders=(
("item.description", "Bezeichnung der Position"),
("item.amount", "Betrag der Position"),
),
sample="{{item.description}}: {{item.amount}} Euro",
),
),
),
@@ -157,6 +185,28 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
)
def placeholder_entries(key: str) -> list[PlaceholderEntry]:
"""Rows for the placeholder chooser. Repeat blocks contribute a complete,
ready-to-edit block -- inserting one has to leave a working template behind,
not a description of one."""
spec = template_spec(key)
entries = [
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description)
for name, description in spec.placeholders
]
for block in spec.blocks:
entries.append(
PlaceholderEntry(
f"{{{{#{block.name}}}}}{{{{/{block.name}}}}}", block.snippet, block.description
)
)
entries.extend(
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description, indented=True)
for name, description in block.placeholders
)
return entries
def template_spec(key: str) -> MailTemplateSpec:
for spec in MAIL_TEMPLATES:
if spec.key == key:
@@ -209,15 +259,12 @@ def validate_mail_template(key: str, subject: str, body: str) -> None:
while editing the template instead of during a send run."""
spec = template_spec(key)
known = {name for name, _help in spec.placeholders}
block_names = {name for name, _help, _items in spec.blocks}
block_names = {block.name for block in spec.blocks}
unknown: set[str] = set()
for block_name, _help, item_placeholders in spec.blocks:
for match in _blocks_of(body, block_name):
unknown.update(
name
for name in _placeholder_names(match)
if name not in known and name not in {item for item, _text in item_placeholders}
)
for block in spec.blocks:
block_known = known | {name for name, _help in block.placeholders}
for match in _blocks_of(body, block.name):
unknown.update(name for name in _placeholder_names(match) if name not in block_known)
remaining = body
for block_name in block_names:
remaining = _strip_blocks(remaining, block_name)
+21 -13
View File
@@ -11,6 +11,7 @@ from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplateError,
default_mail_template,
placeholder_entries,
template_spec,
)
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
@@ -81,6 +82,7 @@ class OptionsDialog(tk.Toplevel):
self.mail_template_var = tk.StringVar(value=MAIL_TEMPLATES[0].label)
self.mail_template_subject_var = tk.StringVar()
self.mail_template_hint_var = tk.StringVar()
self.mail_template_snippets: dict[str, str] = {}
self.title("Optionen")
self.transient(master.winfo_toplevel())
self.grab_set()
@@ -1031,18 +1033,15 @@ class OptionsDialog(tk.Toplevel):
self.mail_template_body.delete("1.0", "end")
self.mail_template_body.insert("1.0", body)
self.mail_template_placeholders.delete(*self.mail_template_placeholders.get_children())
for name, description in spec.placeholders:
self.mail_template_placeholders.insert(
"", "end", values=(f"{{{{{name}}}}}", description)
# The row text is a readable label; what actually gets inserted is the entry's
# snippet, which for a repeat block is the whole block and not its description.
self.mail_template_snippets = {}
for entry in placeholder_entries(key):
label = f" {entry.label}" if entry.indented else entry.label
row = self.mail_template_placeholders.insert(
"", "end", values=(label, entry.description)
)
for name, description, items in spec.blocks:
self.mail_template_placeholders.insert(
"", "end", values=(f"{{{{#{name}}}}}{{{{/{name}}}}}", description)
)
for item_name, item_description in items:
self.mail_template_placeholders.insert(
"", "end", values=(f" {{{{{item_name}}}}}", item_description)
)
self.mail_template_snippets[row] = entry.snippet
def _capture_mail_template(self) -> None:
if not hasattr(self, "mail_template_body"):
@@ -1081,10 +1080,19 @@ class OptionsDialog(tk.Toplevel):
selected = self.mail_template_placeholders.selection()
if not selected:
return
value = str(self.mail_template_placeholders.item(selected[0], "values")[0]).strip()
self.mail_template_body.insert("insert", value)
snippet = self.mail_template_snippets.get(selected[0])
if not snippet:
return
# A multi-line block only works on lines of its own, so it starts on a fresh
# one instead of being appended to whatever the cursor sat behind.
if "\n" in snippet.strip() and not self._mail_cursor_at_line_start():
snippet = "\n" + snippet
self.mail_template_body.insert("insert", snippet)
self.mail_template_body.focus_set()
def _mail_cursor_at_line_start(self) -> bool:
return str(self.mail_template_body.index("insert")).split(".")[1] == "0"
def _save_mail_templates(self) -> None:
self._capture_mail_template()
for key, (subject, body) in self.mail_template_edits.items():