Files
CCMA/src/ccma/ui/options_dialog.py
T
Marcel PeterkauandClaude Opus 5 c46662561e Reject unbalanced repeat markers in mail templates
validate_mail_template() only looked at placeholder names, and "{{#claims}}" is
not a placeholder -- so an unclosed block or a stray "{{/claims}}" passed the
check and rendered as itself: the member would read the marker in their mail.

Repeat markers are now checked structurally: every one names a block the template
actually has, openers and closers pair up in order, blocks do not nest (the
renderer does not support it either), and the subject takes no markers at all.
Each case explains what is wrong and what is missing.

The same check runs before sending, not just before saving: the templates are
plain files in the store and can be edited outside CCMA, where refusing to send
beats mailing a marker. Saving several edited templates now validates all of
them before writing the first, so a mistake in one no longer leaves the others
half-saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:35 +02:00

1130 lines
52 KiB
Python

from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from pathlib import Path
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,
normalize_anniversary_intervals,
)
from ccma.services.mail_delivery import list_imap_folders, test_imap_connection, test_smtp_connection
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore
from ccma.ui.labels import (
EMAIL_DELIVERY_MODE_LABELS,
EMAIL_SECURITY_LABELS,
THEME_LABELS,
display_label,
storage_key,
)
class OptionsDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
config: AppConfig,
repository: MemberRepository,
on_saved: Callable[[bool], None] | None = None,
):
super().__init__(master)
self.config_obj = config
self.repository = repository
self.on_saved = on_saved
self.icons = IconStore(self)
self.store_var = tk.StringVar(value=config.store_path)
self.gnucash_var = tk.StringVar(value=config.gnucash_path)
self.theme_var = tk.StringVar(value=display_label(THEME_LABELS, config.theme_mode))
self.housekeeper_var = tk.BooleanVar(value=config.run_housekeeper_on_startup)
self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before))
self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after))
self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before))
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals)
self.retroactive_claims_var = tk.BooleanVar(value=config.retroactive_claims)
self.optional_member_field_vars = {
field: tk.BooleanVar(value=field in config.optional_member_fields)
for field in HOUSEKEEPER_MEMBER_FIELD_LABELS
}
number_policy = repository.get_member_number_policy()
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
self.number_strategy_var = tk.StringVar(value=number_policy["allocation_strategy"])
self.number_preview_var = tk.StringVar()
organization = repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict):
organization = {}
self.organization_vars = {
key: tk.StringVar(value=str(organization.get(key, "")))
for key in (
"name", "street", "postal_code", "city", "country", "email", "phone",
"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()
self.resizable(True, True)
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
# A ttk.Notebook sizes itself to its tallest/widest tab, so once every tab
# (including "Mahnungen" with its two tables) is built, the natural required
# size reflects whichever one actually needs the most room -- open at that size
# instead of a static guess that clips a table/buttons at the bottom. The floor
# keeps the window from opening awkwardly small on short content.
self.update_idletasks()
width = max(900, self.winfo_reqwidth())
height = max(650, self.winfo_reqheight())
self.geometry(f"{width}x{height}")
self.minsize(760, 540)
self.after_idle(self._center_on_parent)
def _build_ui(self) -> None:
root = ttk.Frame(self, padding=16)
root.grid(row=0, column=0, sticky="nsew")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
notebook = ttk.Notebook(root)
notebook.grid(row=0, column=0, sticky="nsew")
paths = ttk.Frame(notebook, padding=16)
appearance = ttk.Frame(notebook, padding=16)
member_numbers = ttk.Frame(notebook, padding=16)
organization = ttk.Frame(notebook, padding=16)
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")
notebook.add(member_numbers, text="Mitgliedsnummern")
notebook.add(organization, text="Verein / Absender")
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)
self._build_member_numbers(member_numbers)
self._build_organization(organization)
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))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
save_icon = self.icons.get("content-save", 16)
save = ttk.Button(
buttons,
text="Speichern",
image=save_icon,
compound="left",
style="Accent.TButton",
command=self._save,
)
save.image = save_icon
save.pack(side="left")
def _build_paths(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
folder_icon = self.icons.get("folder-open", 18)
file_icon = self.icons.get("file-search", 18) or folder_icon
ttk.Label(parent, text="Mitglieder-Store").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(parent, textvariable=self.store_var, width=62).grid(row=0, column=1, sticky="ew", pady=6)
browse_store = ttk.Button(
parent,
text="Auswählen",
image=folder_icon,
compound="left",
command=self._choose_store,
)
browse_store.image = folder_icon
browse_store.grid(row=0, column=2, padx=(8, 0), pady=6)
ttk.Label(
parent,
text="Ein Wechsel wird nach einem Neustart aktiv. Neue Verzeichnisse können angelegt werden.",
style="Muted.TLabel",
wraplength=620,
).grid(row=1, column=1, columnspan=2, sticky="w", pady=(0, 12))
ttk.Label(parent, text="GnuCash-Datei").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(parent, textvariable=self.gnucash_var).grid(row=2, column=1, sticky="ew", pady=6)
browse_gnucash = ttk.Button(
parent,
text="Auswählen",
image=file_icon,
compound="left",
command=self._choose_gnucash,
)
browse_gnucash.image = file_icon
browse_gnucash.grid(row=2, column=2, padx=(8, 0), pady=6)
ttk.Label(
parent,
text="Optional. Die Datei wird zukünftig ausschließlich lesend eingebunden.",
style="Muted.TLabel",
).grid(row=3, column=1, columnspan=2, sticky="w")
def _build_appearance(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Label(parent, text="Farbschema").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Combobox(
parent,
textvariable=self.theme_var,
values=list(THEME_LABELS.values()),
state="readonly",
width=18,
).grid(row=0, column=1, sticky="w", pady=6)
ttk.Label(
parent,
text="Das Theme wird direkt nach dem Speichern umgeschaltet.",
style="Muted.TLabel",
).grid(row=1, column=1, sticky="w")
def _build_organization(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
fields = (
("Vereinsname", "name"),
("Straße und Hausnummer", "street"),
("Postleitzahl", "postal_code"),
("Ort", "city"),
("Land", "country"),
("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
("Webseite", "website"),
("IBAN", "iban"),
("BIC", "bic"),
("SEPA-Gläubiger-ID", "creditor_id"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(parent, textvariable=self.organization_vars[key], width=48).grid(
row=row, column=1, sticky="ew", pady=5
)
def _build_automation(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Checkbutton(
parent,
text="Hausmeister beim Programmstart ausführen",
variable=self.housekeeper_var,
style="Switch",
).grid(row=0, column=0, columnspan=3, sticky="w", pady=6)
ttk.Label(
parent,
text="Der Lauf prüft nur und führt keine Mahnungen oder Statusänderungen automatisch aus.",
style="Muted.TLabel",
wraplength=620,
).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 18))
ttk.Label(parent, text="Geburtstage melden").grid(row=2, column=0, sticky="w", pady=6)
birthday_window = ttk.Frame(parent)
birthday_window.grid(row=2, column=1, sticky="w", pady=6)
ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_before_var, width=5).pack(
side="left"
)
ttk.Label(birthday_window, text="Tage vorher / ").pack(side="left", padx=(6, 0))
ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_after_var, width=5).pack(
side="left"
)
ttk.Label(birthday_window, text="Tage nachher").pack(side="left", padx=(6, 0))
ttk.Label(parent, text="Jubiläen melden").grid(row=3, column=0, sticky="w", pady=6)
anniversary_window = ttk.Frame(parent)
anniversary_window.grid(row=3, column=1, sticky="w", pady=6)
ttk.Spinbox(
anniversary_window,
from_=0,
to=365,
textvariable=self.anniversary_before_var,
width=5,
).pack(side="left")
ttk.Label(anniversary_window, text="Tage vorher / ").pack(side="left", padx=(6, 0))
ttk.Spinbox(
anniversary_window,
from_=0,
to=365,
textvariable=self.anniversary_after_var,
width=5,
).pack(side="left")
ttk.Label(anniversary_window, text="Tage nachher").pack(side="left", padx=(6, 0))
ttk.Label(parent, text="Jubiläumsintervalle").grid(row=4, column=0, sticky="w", pady=6)
ttk.Entry(parent, textvariable=self.anniversary_intervals_var).grid(
row=4, column=1, sticky="ew", pady=6
)
ttk.Label(
parent,
text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.",
style="Muted.TLabel",
).grid(row=5, column=1, sticky="w")
ttk.Checkbutton(
parent,
text="Beitragsforderungen rückwirkend seit Beitritt anlegen",
variable=self.retroactive_claims_var,
style="Switch",
).grid(row=6, column=0, columnspan=3, sticky="w", pady=(18, 0))
optional_fields = ttk.LabelFrame(parent, text="Bei leeren Mitgliedsfeldern nicht meckern", padding=12)
optional_fields.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(18, 0))
for column in range(2):
optional_fields.columnconfigure(column, weight=1)
for index, (field, label) in enumerate(HOUSEKEEPER_MEMBER_FIELD_LABELS.items()):
ttk.Checkbutton(
optional_fields,
text=label,
variable=self.optional_member_field_vars[field],
).grid(row=index // 2, column=index % 2, sticky="w", padx=(0, 16), pady=4)
def _build_member_numbers(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Checkbutton(
parent,
text="Mitgliedsnummern manuell angeben",
variable=self.manual_numbers_var,
style="Switch",
command=self._update_member_number_controls,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 16))
ttk.Label(parent, text="Automatisches Pattern").grid(
row=1, column=0, sticky="w", padx=(0, 12), pady=6
)
self.pattern_entry = ttk.Entry(parent, textvariable=self.number_pattern_var, width=36)
self.pattern_entry.grid(row=1, column=1, sticky="ew", pady=6)
ttk.Label(
parent,
text="Platzhalter: {number}, {number:04d} und optional {year}",
style="Muted.TLabel",
).grid(row=2, column=1, sticky="w", pady=(0, 12))
ttk.Label(parent, text="Vergabestrategie").grid(
row=3, column=0, sticky="nw", padx=(0, 12), pady=6
)
strategy_frame = ttk.Frame(parent)
strategy_frame.grid(row=3, column=1, sticky="w", pady=6)
ttk.Radiobutton(
strategy_frame,
text="Lücken füllen (nächste freie Nummer)",
variable=self.number_strategy_var,
value="next_free",
command=self._update_number_preview,
).pack(anchor="w")
ttk.Radiobutton(
strategy_frame,
text="Immer Max++ (höchste Nummer + 1)",
variable=self.number_strategy_var,
value="max_plus_one",
command=self._update_number_preview,
).pack(anchor="w", pady=(4, 0))
ttk.Label(parent, text="Nächste Nummer").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
row=4, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text=(
"Die Strategie wird im Mitglieder-Store gespeichert. Die Vergabe ist gesperrt, "
"solange ein neues Mitglied angelegt wird, damit keine Nummer doppelt entsteht."
),
style="Muted.TLabel",
wraplength=620,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(14, 0))
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
self._update_member_number_controls()
def _update_member_number_controls(self) -> None:
if self.manual_numbers_var.get():
self.pattern_entry.state(["disabled"])
self.number_preview_var.set("MANUELLE EINGABE")
else:
self.pattern_entry.state(["!disabled"])
self._update_number_preview()
def _update_number_preview(self) -> None:
if self.manual_numbers_var.get():
return
pattern = self.number_pattern_var.get()
try:
preview = self.repository.preview_member_number(
pattern, self.number_strategy_var.get()
)
except RepositoryError as exc:
preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview)
def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
policy = self.repository.get_reminder_policy()
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
row=0, column=0, sticky="w", pady=6
)
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
row=0, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
style="Muted.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
levels_frame.columnconfigure(0, weight=1)
self.levels = [dict(level) for level in policy["levels"]]
self.levels_tree = ttk.Treeview(
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
)
for key, title, width in (
("level", "Stufe", 55),
("name", "Name", 220),
("fee", "Gebühr", 90),
("deadline", "Frist (Tage)", 100),
):
self.levels_tree.heading(key, text=title)
self.levels_tree.column(key, width=width, anchor="w")
self.levels_tree.grid(row=0, column=0, sticky="ew")
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
level_controls = ttk.Frame(levels_frame)
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.level_name_var = tk.StringVar()
self.level_fee_var = tk.StringVar()
self.level_deadline_var = tk.StringVar()
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
ttk.Label(
parent,
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
style="Muted.TLabel",
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
items_frame.columnconfigure(0, weight=1)
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
self.standard_items_tree = ttk.Treeview(
items_frame, columns=("description", "amount"), show="headings", height=5
)
self.standard_items_tree.heading("description", text="Beschreibung")
self.standard_items_tree.heading("amount", text="Standardbetrag")
self.standard_items_tree.column("description", width=260, anchor="w")
self.standard_items_tree.column("amount", width=120, anchor="w")
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
self.standard_items_tree.bind(
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
)
item_controls = ttk.Frame(items_frame)
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.standard_item_description_var = tk.StringVar()
self.standard_item_amount_var = tk.StringVar()
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
side="left", padx=(0, 6)
)
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
ttk.Label(
parent,
text=(
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
),
style="Muted.TLabel",
wraplength=560,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
self._refresh_levels()
self._refresh_standard_items()
def _refresh_levels(self) -> None:
selected = self.levels_tree.selection()
self.levels_tree.delete(*self.levels_tree.get_children())
for index, level in enumerate(self.levels):
self.levels_tree.insert(
"",
"end",
iid=str(index),
values=(
index + 1,
level.get("name", ""),
f"{level.get('fee', '0.00')} EUR",
level.get("payment_deadline_days", 14),
),
)
if selected and self.levels_tree.exists(selected[0]):
self.levels_tree.selection_set(selected[0])
def _load_selected_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
return
level = self.levels[int(selected[0])]
self.level_name_var.set(level.get("name", ""))
self.level_fee_var.set(str(level.get("fee", "0.00")))
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
def _apply_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
name = self.level_name_var.get().strip()
if not name:
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
return
try:
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
deadline = int(self.level_deadline_var.get())
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels[int(selected[0])] = {
"name": name,
"fee": money_text(fee),
"payment_deadline_days": deadline,
}
self._refresh_levels()
def _add_level(self) -> None:
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
try:
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
deadline = int(self.level_deadline_var.get() or "14")
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
self._refresh_levels()
def _remove_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
del self.levels[int(selected[0])]
self._refresh_levels()
def _refresh_standard_items(self) -> None:
selected = self.standard_items_tree.selection()
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
for index, item in enumerate(self.standard_items):
self.standard_items_tree.insert(
"",
"end",
iid=str(index),
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
)
if selected and self.standard_items_tree.exists(selected[0]):
self.standard_items_tree.selection_set(selected[0])
def _load_selected_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
return
item = self.standard_items[int(selected[0])]
self.standard_item_description_var.set(item.get("description", ""))
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
def _apply_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
description = self.standard_item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items[int(selected[0])] = {
"description": description,
"default_amount": money_text(amount),
}
self._refresh_standard_items()
def _add_standard_item(self) -> None:
description = self.standard_item_description_var.get().strip() or "Neue Position"
try:
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
self._refresh_standard_items()
def _remove_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
del self.standard_items[int(selected[0])]
self._refresh_standard_items()
def _build_email(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
email_settings = self.repository.get_email_settings()
self.email_delivery_mode_var = tk.StringVar(
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
)
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
ttk.Combobox(
parent,
textvariable=self.email_delivery_mode_var,
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
state="readonly",
width=42,
).grid(row=0, column=1, sticky="w", pady=6)
ttk.Label(
parent,
text=(
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
smtp_frame.columnconfigure(1, weight=1)
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
self.smtp_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
)
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
)
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
smtp_frame,
textvariable=self.smtp_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="•").grid(
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
)
imap_frame = ttk.LabelFrame(parent, text="IMAP (für Entwürfe & Gesendet)", padding=10)
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
imap_frame.columnconfigure(1, weight=1)
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
self.imap_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
)
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
self.imap_sent_enabled_var = tk.BooleanVar(value=email_settings["imap_sent_enabled"])
self.imap_sent_folder_var = tk.StringVar(value=email_settings["imap_sent_folder"])
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
)
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(imap_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
imap_frame,
textvariable=self.imap_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="•").grid(
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
self.imap_drafts_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_drafts_folder_var)
self.imap_drafts_folder_combo.grid(row=4, column=1, columnspan=3, sticky="ew", pady=4)
ttk.Checkbutton(
imap_frame,
text="Direkt versandte Mahnungen/Info-Mails zusätzlich in einen Gesendet-Ordner ablegen",
variable=self.imap_sent_enabled_var,
).grid(row=5, column=0, columnspan=4, sticky="w", pady=(8, 4))
ttk.Label(imap_frame, text="Gesendet-Ordner").grid(row=6, column=0, sticky="w", pady=4, padx=(0, 8))
self.imap_sent_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_sent_folder_var)
self.imap_sent_folder_combo.grid(row=6, column=1, columnspan=3, sticky="ew", pady=4)
buttons_row = ttk.Frame(imap_frame)
buttons_row.grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
ttk.Button(buttons_row, text="Verbindung testen", command=self._test_imap_connection).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons_row, text="Ordnerliste laden", command=self._load_imap_folders).pack(side="left")
ttk.Label(
imap_frame,
text=(
"„Ordnerliste laden“ ruft die Ordner live vom Server ab (Zugangsdaten oben müssen "
"dafür bereits ausgefüllt sein) und füllt beide Dropdowns; freie Eingabe bleibt "
"möglich -- ein dabei angegebener, noch nicht vorhandener Ordner wird bei der "
"nächsten Ablage automatisch angelegt."
),
style="Muted.TLabel",
wraplength=660,
).grid(row=8, column=0, columnspan=4, sticky="w", pady=(6, 0))
ttk.Label(
parent,
text=(
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
"lässt sich so ein eigenes Postfach hinterlegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
def _collect_email_settings(self) -> dict:
return {
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
"smtp_host": self.smtp_host_var.get(),
"smtp_port": self.smtp_port_var.get(),
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
"smtp_username": self.smtp_username_var.get(),
"smtp_password": self.smtp_password_var.get(),
"imap_host": self.imap_host_var.get(),
"imap_port": self.imap_port_var.get(),
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
"imap_username": self.imap_username_var.get(),
"imap_password": self.imap_password_var.get(),
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
"imap_sent_enabled": self.imap_sent_enabled_var.get(),
"imap_sent_folder": self.imap_sent_folder_var.get(),
}
def _test_smtp_connection(self) -> None:
settings = self._collect_email_settings()
try:
settings["smtp_port"] = int(settings["smtp_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
test_smtp_connection(settings)
except RepositoryError as exc:
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _test_imap_connection(self) -> None:
settings = self._collect_email_settings()
try:
settings["imap_port"] = int(settings["imap_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
test_imap_connection(settings)
except RepositoryError as exc:
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _load_imap_folders(self) -> None:
settings = self._collect_email_settings()
try:
settings["imap_port"] = int(settings["imap_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
folders = list_imap_folders(settings)
except RepositoryError as exc:
messagebox.showerror("Ordnerliste konnte nicht geladen werden", str(exc), parent=self)
return
self.imap_drafts_folder_combo.configure(values=folders)
self.imap_sent_folder_combo.configure(values=folders)
messagebox.showinfo("Ordnerliste geladen", f"{len(folders)} Ordner gefunden.", parent=self)
def _choose_store(self) -> None:
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
initial = current if current.is_dir() else current.parent
selected = filedialog.askdirectory(
parent=self,
title="Mitglieder-Store auswählen oder anlegen",
initialdir=str(initial),
mustexist=False,
)
if selected:
self.store_var.set(selected)
def _choose_gnucash(self) -> None:
current = Path(self.gnucash_var.get()).expanduser() if self.gnucash_var.get().strip() else Path.home()
initial = current.parent if current.suffix else current
selected = filedialog.askopenfilename(
parent=self,
title="GnuCash-Datei auswählen",
initialdir=str(initial),
filetypes=(
("GnuCash-Dateien", "*.gnucash *.xac"),
("Alle Dateien", "*"),
),
)
if selected:
self.gnucash_var.set(selected)
def _save(self) -> None:
number_mode = "manual" if self.manual_numbers_var.get() else "automatic"
number_pattern = self.number_pattern_var.get().strip()
try:
validate_member_number_pattern(number_pattern)
except RepositoryError as exc:
messagebox.showerror("Ungültiges Mitgliedsnummern-Pattern", str(exc), parent=self)
return
try:
birthday_before = _parse_day_window(self.birthday_before_var.get(), "Geburtstage vorher")
birthday_after = _parse_day_window(self.birthday_after_var.get(), "Geburtstage nachher")
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
return
email_settings = self._collect_email_settings()
try:
email_settings["smtp_port"] = int(email_settings["smtp_port"])
email_settings["imap_port"] = int(email_settings["imap_port"])
except ValueError:
messagebox.showerror(
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
)
return
store_text = self.store_var.get().strip()
if not store_text:
messagebox.showerror(
"Ungültige Einstellung", "Ein Mitglieder-Store ist erforderlich.", parent=self
)
return
store = Path(store_text).expanduser().resolve()
if store.exists() and not store.is_dir():
messagebox.showerror(
"Ungültiger Store", "Der Mitglieder-Store ist kein Verzeichnis.", parent=self
)
return
if not store.exists():
create = messagebox.askyesno(
"Store anlegen",
f"Das Verzeichnis existiert noch nicht:\n\n{store}\n\nJetzt anlegen?",
parent=self,
)
if not create:
return
try:
store.mkdir(parents=True)
except OSError as exc:
messagebox.showerror("Store konnte nicht angelegt werden", str(exc), parent=self)
return
gnucash_text = self.gnucash_var.get().strip()
gnucash = Path(gnucash_text).expanduser().resolve() if gnucash_text else None
if gnucash and not gnucash.is_file():
messagebox.showerror(
"Ungültige GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self
)
return
old_store = Path(self.config_obj.store_path).expanduser().resolve()
store_changed = old_store != store
self.config_obj.store_path = str(store)
self.config_obj.gnucash_path = str(gnucash) if gnucash else ""
self.config_obj.theme_mode = storage_key(THEME_LABELS, self.theme_var.get())
self.config_obj.run_housekeeper_on_startup = self.housekeeper_var.get()
self.config_obj.birthday_days_before = birthday_before
self.config_obj.birthday_days_after = birthday_after
self.config_obj.anniversary_days_before = anniversary_before
self.config_obj.anniversary_days_after = anniversary_after
self.config_obj.anniversary_intervals = anniversary_intervals
self.config_obj.retroactive_claims = self.retroactive_claims_var.get()
self.config_obj.optional_member_fields = tuple(
field for field, variable in self.optional_member_field_vars.items() if variable.get()
)
try:
self.config_obj.save()
self.repository.save_member_number_policy(
mode=number_mode,
pattern=number_pattern,
allocation_strategy=self.number_strategy_var.get(),
)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
self.repository.save_reminder_policy(
grace_days_after_due=grace_days_after_due,
levels=self.levels,
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
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()
x = parent.winfo_rootx() + max(0, (parent.winfo_width() - self.winfo_width()) // 2)
y = parent.winfo_rooty() + max(0, (parent.winfo_height() - self.winfo_height()) // 2)
self.geometry(f"+{x}+{y}")
def _parse_day_window(value: str, label: str) -> int:
try:
parsed = int(value.strip())
except ValueError as exc:
raise ValueError(f"{label} muss eine ganze Zahl sein.") from exc
if not 0 <= parsed <= 365:
raise ValueError(f"{label} muss zwischen 0 und 365 Tagen liegen.")
return parsed