mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
The encrypted volume holding the member data can be mounted without write access, but starting against such a store failed: the housekeeper takes a lock file before doing anything, so its startup pass died with a PermissionError and took the whole start with it. The store is now probed with an actual write once at startup -- permissions, mount options and filesystem state all matter, and only an attempt covers them together -- and a read-only store opens as a read-only session. The housekeeper is skipped rather than attempted, every write inside the repository goes through one guard that reports ReadOnlyStoreError (a RepositoryError, so the dialogs already handle it) instead of letting an OS error surface, and the services that archive into the member file check before they start sending or rendering. The session says so permanently: a warning banner above the tabs, "NUR LESEN" in the window title and status bar, and refused actions explaining why. Program settings still save -- they live in the user's config directory -- while the store-backed ones are skipped with a notice. "Erneut prüfen" picks up a volume that was remounted writable without restarting. A store that was never initialized still fails, but says that creating one needs write access. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
946 lines
44 KiB
Python
946 lines
44 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.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",
|
|
)
|
|
}
|
|
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)
|
|
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(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)
|
|
|
|
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()
|
|
)
|
|
# Program settings live in the user's config directory and stay writable even
|
|
# when the member store does not -- so they are saved either way, and only the
|
|
# store-backed settings are skipped (with a notice) on a read-only store.
|
|
store_read_only = self.repository.read_only
|
|
try:
|
|
self.config_obj.save()
|
|
if not store_read_only:
|
|
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)
|
|
except (OSError, RepositoryError) as exc:
|
|
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
|
return
|
|
if store_read_only:
|
|
messagebox.showwarning(
|
|
"Store ist schreibgeschützt",
|
|
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
|
|
"Mitgliedsnummern, Mahnungen und E-Mail-Versand konnten nicht gespeichert "
|
|
"werden, weil der Mitglieder-Store nur lesend eingebunden ist.",
|
|
parent=self,
|
|
)
|
|
if self.on_saved:
|
|
self.on_saved(store_changed)
|
|
self.destroy()
|
|
|
|
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
|