mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Render every outgoing e-mail from an editable text template
The wording of the dunning and SEPA pre-notification mails was hard-coded in
Python, so adjusting a single sentence required a new release. Both texts now
live in plain text templates that ship as defaults, are copied into the store's
templates/mail/ directory on first start and can be edited there or under
Optionen -> E-Mail-Vorlagen; an existing file is never overwritten and a deleted
one is restored from the shipped default.
A template carries its subject in the first line and the body after a blank
line. Placeholders use the same {{ ... }} syntax as the document templates and
share their member/organization values, so a name means the same thing in a
letter and in the mail that carries it. Unknown placeholders are rejected while
editing instead of during a send run, a line holding nothing but placeholders
that render empty is dropped, and {{#claims}} ... {{/claims}} repeats per entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3807bf7dc
commit
d6cd58a788
@@ -7,6 +7,12 @@ from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import decimal_value, money_text
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplateError,
|
||||
default_mail_template,
|
||||
template_spec,
|
||||
)
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
@@ -67,6 +73,14 @@ class OptionsDialog(tk.Toplevel):
|
||||
"website", "iban", "bic", "creditor_id",
|
||||
)
|
||||
}
|
||||
# Mail templates are edited one at a time but saved together with the rest of
|
||||
# the options, so the edits of every tab are kept in memory until "Speichern".
|
||||
self.mail_template_key = MAIL_TEMPLATES[0].key
|
||||
self.mail_template_edits: dict[str, tuple[str, str]] = {}
|
||||
self.mail_template_original: dict[str, tuple[str, str]] = {}
|
||||
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.title("Optionen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
@@ -102,6 +116,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
automation = ttk.Frame(notebook, padding=16)
|
||||
reminders = ttk.Frame(notebook, padding=16)
|
||||
email = ttk.Frame(notebook, padding=16)
|
||||
mail_templates = ttk.Frame(notebook, padding=16)
|
||||
changelog = ChangelogView(notebook)
|
||||
notebook.add(paths, text="Pfade")
|
||||
notebook.add(appearance, text="Darstellung")
|
||||
@@ -110,6 +125,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
notebook.add(automation, text="Hausmeister")
|
||||
notebook.add(reminders, text="Mahnungen")
|
||||
notebook.add(email, text="E-Mail-Versand")
|
||||
notebook.add(mail_templates, text="E-Mail-Vorlagen")
|
||||
notebook.add(changelog, text="Changelog")
|
||||
self._build_paths(paths)
|
||||
self._build_appearance(appearance)
|
||||
@@ -118,6 +134,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
self._build_automation(automation)
|
||||
self._build_reminders(reminders)
|
||||
self._build_email(email)
|
||||
self._build_mail_templates(mail_templates)
|
||||
|
||||
buttons = ttk.Frame(root)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||
@@ -907,6 +924,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
standard_fee_items=self.standard_items,
|
||||
)
|
||||
self.repository.save_email_settings(**email_settings)
|
||||
self._save_mail_templates()
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
@@ -914,6 +932,166 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.on_saved(store_changed)
|
||||
self.destroy()
|
||||
|
||||
def _build_mail_templates(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(4, weight=1)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Die Texte aller vom Programm erzeugten E-Mails liegen als bearbeitbare "
|
||||
"Vorlagen im Mitglieder-Store unter templates/mail/. Platzhalter in doppelten "
|
||||
"geschweiften Klammern werden beim Versand mit den Daten des Mitglieds gefüllt."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=820,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
chooser = ttk.Frame(parent)
|
||||
chooser.grid(row=1, column=0, columnspan=2, sticky="ew")
|
||||
chooser.columnconfigure(1, weight=1)
|
||||
ttk.Label(chooser, text="Vorlage").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
selector = ttk.Combobox(
|
||||
chooser,
|
||||
textvariable=self.mail_template_var,
|
||||
values=[spec.label for spec in MAIL_TEMPLATES],
|
||||
state="readonly",
|
||||
width=32,
|
||||
)
|
||||
selector.grid(row=0, column=1, sticky="w")
|
||||
selector.bind("<<ComboboxSelected>>", lambda _event: self._select_mail_template())
|
||||
ttk.Button(
|
||||
chooser, text="Standardtext wiederherstellen", command=self._reset_mail_template
|
||||
).grid(row=0, column=2, sticky="e", padx=(12, 0))
|
||||
ttk.Label(
|
||||
parent, textvariable=self.mail_template_hint_var, style="Muted.TLabel", wraplength=820
|
||||
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 12))
|
||||
|
||||
subject = ttk.Frame(parent)
|
||||
subject.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
subject.columnconfigure(1, weight=1)
|
||||
ttk.Label(subject, text="Betreff").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
ttk.Entry(subject, textvariable=self.mail_template_subject_var).grid(
|
||||
row=0, column=1, sticky="ew"
|
||||
)
|
||||
|
||||
editor = ttk.Frame(parent)
|
||||
editor.grid(row=4, column=0, sticky="nsew")
|
||||
editor.columnconfigure(0, weight=1)
|
||||
editor.rowconfigure(0, weight=1)
|
||||
self.mail_template_body = tk.Text(editor, wrap="word", height=16, width=70, undo=True)
|
||||
self.mail_template_body.grid(row=0, column=0, sticky="nsew")
|
||||
body_scroll = ttk.Scrollbar(editor, orient="vertical", command=self.mail_template_body.yview)
|
||||
body_scroll.grid(row=0, column=1, sticky="ns")
|
||||
self.mail_template_body.configure(yscrollcommand=body_scroll.set)
|
||||
|
||||
placeholders = ttk.Frame(parent)
|
||||
placeholders.grid(row=4, column=1, sticky="nsew", padx=(12, 0))
|
||||
placeholders.columnconfigure(0, weight=1)
|
||||
placeholders.rowconfigure(1, weight=1)
|
||||
ttk.Label(placeholders, text="Platzhalter (Doppelklick fügt ein)").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 4)
|
||||
)
|
||||
self.mail_template_placeholders = ttk.Treeview(
|
||||
placeholders, columns=("name", "help"), show="headings", selectmode="browse", height=14
|
||||
)
|
||||
for key, title, width in (("name", "Platzhalter", 210), ("help", "Bedeutung", 230)):
|
||||
self.mail_template_placeholders.heading(key, text=title)
|
||||
self.mail_template_placeholders.column(key, width=width, anchor="w", stretch=key == "help")
|
||||
self.mail_template_placeholders.grid(row=1, column=0, sticky="nsew")
|
||||
placeholder_scroll = ttk.Scrollbar(
|
||||
placeholders, orient="vertical", command=self.mail_template_placeholders.yview
|
||||
)
|
||||
placeholder_scroll.grid(row=1, column=1, sticky="ns")
|
||||
self.mail_template_placeholders.configure(yscrollcommand=placeholder_scroll.set)
|
||||
self.mail_template_placeholders.bind(
|
||||
"<Double-1>", lambda _event: self._insert_mail_placeholder()
|
||||
)
|
||||
self.mail_template_placeholders.bind(
|
||||
"<Return>", lambda _event: self._insert_mail_placeholder()
|
||||
)
|
||||
self._load_mail_template(self.mail_template_key)
|
||||
|
||||
def _load_mail_template(self, key: str) -> None:
|
||||
if key not in self.mail_template_edits:
|
||||
try:
|
||||
template = self.repository.get_mail_template(key)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror(
|
||||
"Vorlage konnte nicht geladen werden", str(exc), parent=self
|
||||
)
|
||||
return
|
||||
self.mail_template_edits[key] = (template.subject, template.body)
|
||||
self.mail_template_original[key] = (template.subject, template.body)
|
||||
subject, body = self.mail_template_edits[key]
|
||||
self.mail_template_key = key
|
||||
spec = template_spec(key)
|
||||
self.mail_template_var.set(spec.label)
|
||||
self.mail_template_hint_var.set(f"{spec.description} (Datei: {spec.filename})")
|
||||
self.mail_template_subject_var.set(subject)
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
def _capture_mail_template(self) -> None:
|
||||
if not hasattr(self, "mail_template_body"):
|
||||
return
|
||||
self.mail_template_edits[self.mail_template_key] = (
|
||||
self.mail_template_subject_var.get(),
|
||||
self.mail_template_body.get("1.0", "end-1c"),
|
||||
)
|
||||
|
||||
def _select_mail_template(self) -> None:
|
||||
self._capture_mail_template()
|
||||
label = self.mail_template_var.get()
|
||||
key = next((spec.key for spec in MAIL_TEMPLATES if spec.label == label), None)
|
||||
if key:
|
||||
self._load_mail_template(key)
|
||||
|
||||
def _reset_mail_template(self) -> None:
|
||||
key = self.mail_template_key
|
||||
spec = template_spec(key)
|
||||
if not messagebox.askyesno(
|
||||
"Standardtext wiederherstellen",
|
||||
f"Soll die Vorlage „{spec.label}“ auf den mitgelieferten Standardtext "
|
||||
"zurückgesetzt werden? Deine Änderungen an dieser Vorlage gehen dabei verloren.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
template = default_mail_template(key)
|
||||
except MailTemplateError as exc:
|
||||
messagebox.showerror("Standardtext nicht verfügbar", str(exc), parent=self)
|
||||
return
|
||||
self.mail_template_edits[key] = (template.subject, template.body)
|
||||
self._load_mail_template(key)
|
||||
|
||||
def _insert_mail_placeholder(self) -> None:
|
||||
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)
|
||||
self.mail_template_body.focus_set()
|
||||
|
||||
def _save_mail_templates(self) -> None:
|
||||
self._capture_mail_template()
|
||||
for key, (subject, body) in self.mail_template_edits.items():
|
||||
if self.mail_template_original.get(key) == (subject, body):
|
||||
continue
|
||||
self.repository.save_mail_template(key, subject=subject, body=body)
|
||||
|
||||
def _center_on_parent(self) -> None:
|
||||
self.update_idletasks()
|
||||
parent = self.master.winfo_toplevel()
|
||||
|
||||
Reference in New Issue
Block a user