Files
CCMA/src/ccma/ui/options_dialog.py
T

375 lines
16 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.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
)
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore
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=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)
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_preview_var = tk.StringVar()
self.title("Optionen")
self.geometry("900x650")
self.minsize(760, 540)
self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(True, True)
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
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)
automation = 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(automation, text="Hausmeister")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
self._build_member_numbers(member_numbers)
self._build_automation(automation)
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=("dark", "light"),
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_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")
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="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
row=3, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text=(
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern "
"werden niemals doppelt erzeugt."
),
style="Muted.TLabel",
wraplength=620,
).grid(row=4, 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)
except RepositoryError as exc:
preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview)
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())
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), 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 = 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
try:
self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
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 _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