mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-14 06:55:20 +02:00
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>
1130 lines
52 KiB
Python
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
|