Merge branch 'dev' into feature/read-only-store

dev gained the editable mail templates, which live in the store like everything
else -- so they follow the same read-only rules: the options dialog skips saving
them, save_mail_template() reports the store instead of a raw PermissionError,
and a store that cannot keep its own copy simply renders from the shipped
default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-28 21:26:40 +02:00
co-authored by Claude Opus 5
24 changed files with 2276 additions and 168 deletions
+16
View File
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
@@ -355,6 +356,13 @@ class MemberTab(ttk.Frame):
self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
claim_actions = ttk.Frame(contribution_tab)
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
claim_actions,
text="Willkommens- und Erstrechnungsmail",
command=self._send_welcome_mail,
).pack(side="left")
contribution_override_tab.columnconfigure(0, weight=1)
contribution_override_tab.rowconfigure(1, weight=1)
@@ -1185,6 +1193,14 @@ class MemberTab(ttk.Frame):
except OSError as exc:
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
def _send_welcome_mail(self) -> None:
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
def _welcome_mail_sent(self) -> None:
self._refresh_documents()
self._refresh_events()
self.on_changed()
def _create_document(self) -> None:
DocumentTemplateDialog(
self,
+200 -2
View File
@@ -7,6 +7,14 @@ 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,
placeholder_entries,
template_spec,
validate_mail_template,
)
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.services.intervals import (
IntervalValidationError,
@@ -67,6 +75,15 @@ 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.mail_template_snippets: dict[str, str] = {}
self.title("Optionen")
self.transient(master.winfo_toplevel())
self.grab_set()
@@ -102,6 +119,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 +128,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 +137,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))
@@ -912,6 +932,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
@@ -919,14 +940,191 @@ class OptionsDialog(tk.Toplevel):
messagebox.showwarning(
"Store ist schreibgeschützt",
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
"Mitgliedsnummern, Mahnungen und E-Mail-Versand konnten nicht gespeichert "
"werden, weil der Mitglieder-Store nur lesend eingebunden ist.",
"Mitgliedsnummern, Mahnungen, E-Mail-Versand und E-Mail-Vorlagen konnten "
"nicht gespeichert werden, weil der Mitglieder-Store nur lesend "
"eingebunden ist.",
parent=self,
)
if self.on_saved:
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())
# 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)
)
self.mail_template_snippets[row] = entry.snippet
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
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()
pending = {
key: value
for key, value in self.mail_template_edits.items()
if self.mail_template_original.get(key) != value
}
# Check every edited template before writing the first one: a mistake in one
# must not leave the others half-saved.
for key, (subject, body) in pending.items():
try:
validate_mail_template(key, subject, body)
except MailTemplateError as exc:
raise RepositoryError(f"{template_spec(key).label}: {exc}") from exc
for key, (subject, body) in pending.items():
self.repository.save_mail_template(key, subject=subject, body=body)
def _center_on_parent(self) -> None:
self.update_idletasks()
parent = self.master.winfo_toplevel()
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from decimal import Decimal
from email.parser import BytesParser
from email.policy import default as default_policy
from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import claim_balance, money_text
from ccma.domain.dates import format_date_for_display
from ccma.services.welcome_mail import (
generate_and_send_welcome_mail,
open_claims,
welcome_mail_bytes,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
class WelcomeMailDialog(tk.Toplevel):
"""Sends the welcome mail that doubles as the first invoice for a new member:
membership start, member number and every claim that is still open."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_sent: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_sent = on_sent
self.claims: list[dict] = []
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.summary_var = tk.StringVar()
self.title("Willkommens- und Erstrechnungsmail")
self.transient(master.winfo_toplevel())
self.resizable(True, True)
self._build_ui()
self._refresh_claims()
self.grab_set()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
ttk.Label(
self,
text=(
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
),
wraplength=640,
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
ttk.Label(
self,
text=(
"Standardmäßig sind alle offenen Forderungen ausgewählt einzelne mit "
"Strg+Klick abwählen."
),
style="Muted.TLabel",
wraplength=640,
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
table = ttk.Frame(self)
table.grid(row=2, column=0, sticky="nsew", padx=14)
table.columnconfigure(0, weight=1)
table.rowconfigure(0, weight=1)
self.table = ttk.Treeview(
table,
columns=("due", "amount", "title"),
show="headings",
selectmode="extended",
height=6,
)
for key, title, width in (
("due", "Fällig", 90),
("amount", "Offen", 90),
("title", "Forderung", 320),
):
self.table.heading(key, text=title)
self.table.column(key, width=width, anchor="w", stretch=key == "title")
self.table.grid(row=0, column=0, sticky="nsew")
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
scroll.grid(row=0, column=1, sticky="ns")
self.table.configure(yscrollcommand=scroll.set)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
)
fields = ttk.Frame(self)
fields.grid(row=4, column=0, sticky="ew", padx=14)
fields.columnconfigure(1, weight=1)
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=5, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
ttk.Button(
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
).pack(side="left", padx=(8, 0))
def _refresh_claims(self) -> None:
try:
self.claims = open_claims(self.repository, self.member_id)
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, claim in enumerate(self.claims):
self.table.insert(
"",
"end",
iid=str(index),
values=(
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_balance(data, claim)),
str(claim.get("description", "") or claim.get("title", "")),
),
)
self.table.selection_set(self.table.get_children())
self._update_summary()
def _selected_claims(self) -> list[dict]:
return [self.claims[int(iid)] for iid in self.table.selection()]
def _update_summary(self) -> None:
data = self.repository.get_contributions(self.member_id)
selected = self._selected_claims()
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
self.summary_var.set(
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt "
f"Gesamtbetrag {money_text(total)} Euro"
)
def _mail_bytes(self) -> bytes:
member = self.repository.get_member(self.member_id)
data = self.repository.get_contributions(self.member_id)
claims = self._selected_claims()
return welcome_mail_bytes(
self.repository,
member=member,
claims=claims,
balances={
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
},
organization=self.repository.get_configuration().get("organization") or {},
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
def _preview(self) -> None:
try:
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
except (OSError, RepositoryError) as exc:
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("Vorschau der Willkommensmail")
window.transient(self)
window.columnconfigure(0, weight=1)
window.rowconfigure(1, weight=1)
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
row=0, column=0, sticky="w"
)
preview = tk.Text(window, wrap="word", width=88, height=28)
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
preview.insert("1.0", message.get_content())
preview.configure(state="disabled")
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
preview.configure(yscrollcommand=scroll.set)
ttk.Button(window, text="Schließen", command=window.destroy).grid(
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
)
def _generate(self) -> None:
selected = self._selected_claims()
if not selected and not messagebox.askyesno(
"Keine Forderung ausgewählt",
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
"erzeugt werden?",
parent=self,
):
return
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_path = None
if delivery_mode == "local":
chosen = filedialog.asksaveasfilename(
parent=self,
title="Willkommensmail als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile="Willkommen.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not chosen:
return
output_path = chosen
try:
result = generate_and_send_welcome_mail(
self.repository,
self.member_id,
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
delivery_mode=delivery_mode,
output_path=output_path,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
return
outcome = {
"local": f"gespeichert:\n{result.export_path}",
"send": "direkt versandt.",
"drafts": "als Entwurf im Postfach abgelegt.",
}[delivery_mode]
messagebox.showinfo(
"Willkommensmail erzeugt",
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
parent=self,
)
self.destroy()
self.on_sent()