mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
The placeholder chooser showed repeat blocks as "{{#claims}} … {{/claims}}" and
inserted exactly that on double-click -- the ellipsis is a label, so the saved
template rendered a literal "…" per claim instead of the claim.
Chooser rows are data now: each carries the text it reads as and, separately,
the snippet it inserts. A block contributes a complete, ready-to-edit block with
a sample line built from its own placeholders, and lands on a line of its own
when the cursor sits behind existing text.
Covered from both sides: the snippets are checked against the template validator
and renderer, and a UI test drives the real dialog -- insert, save, send -- and
asserts the mail carries actual claim lines. The Tk tests share one root (the
icon library binds its images to the first one) and skip without a display.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1119 lines
52 KiB
Python
1119 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,
|
|
)
|
|
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()
|
|
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
|