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, ) 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("", 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("<>", 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( "<>", 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("<>", 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( "", lambda _event: self._insert_mail_placeholder() ) self.mail_template_placeholders.bind( "", 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() 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() 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