diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1791b5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +.coverage +htmlcov/ +.idea/ +.vscode/ + +# Local member stores must never be committed. +member-store/ +*.ccma-lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18095cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chaos Computer Club Mannheim e.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ceeea23 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..07156bd --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1-dev0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c40a774 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "ccma" +dynamic = ["version"] +description = "Chaotic Creature Member Administration for Chaos Computer Club Mannheim e.V." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [{name = "Chaos Computer Club Mannheim e.V."}] +dependencies = [ + "screeninfo>=0.8.1,<1", + "ttkbootstrap-icons", + "ttkbootstrap-icons-mat", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.6", +] + +[project.scripts] +ccma = "ccma.app:main" + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +ccma = ["VERSION", "assets/CHANGELOG.json", "assets/themes/forest/**/*", "assets/themes/forest/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 110 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..658a218 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Install CCMA and its runtime dependencies from pyproject.toml. +-e . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d61de67 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +from pathlib import Path +from shutil import copyfile + +from setuptools import setup +from setuptools.command.build_py import build_py + +ROOT = Path(__file__).resolve().parent + + +class BuildPyWithVersion(build_py): + def run(self) -> None: + super().run() + target = Path(self.build_lib) / "ccma" / "VERSION" + target.parent.mkdir(parents=True, exist_ok=True) + copyfile(ROOT / "VERSION", target) + + +setup( + version=(ROOT / "VERSION").read_text(encoding="utf-8").strip(), + cmdclass={"build_py": BuildPyWithVersion}, +) diff --git a/src/ccma/__init__.py b/src/ccma/__init__.py new file mode 100644 index 0000000..ad7cbdb --- /dev/null +++ b/src/ccma/__init__.py @@ -0,0 +1,5 @@ +from ccma.version import get_version + +__version__ = get_version() + +__all__ = ["__version__"] diff --git a/src/ccma/__main__.py b/src/ccma/__main__.py new file mode 100644 index 0000000..e040fd6 --- /dev/null +++ b/src/ccma/__main__.py @@ -0,0 +1,4 @@ +from ccma.app import main + +if __name__ == "__main__": + main() diff --git a/src/ccma/app.py b/src/ccma/app.py new file mode 100644 index 0000000..789d338 --- /dev/null +++ b/src/ccma/app.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox + +from ccma import __version__ +from ccma.config import load_config +from ccma.domain.dates import setup_system_locale +from ccma.storage.repository import MemberRepository +from ccma.ui.main_window import MainWindow +from ccma.ui.monitors import ( + centered_geometry, + ensure_visible_geometry, + monitor_for_geometry, + preferred_monitor, +) +from ccma.ui.splash import SplashScreen, StartupResult +from ccma.ui.theme import load_theme +from ccma.ui.window_state import is_maximized, maximize + + +class CCMAApp(tk.Tk): + def __init__(self): + super().__init__() + setup_system_locale() + self.config_obj = load_config() + self.title(f"CCMA · v{__version__}") + self.minsize(1050, 650) + load_theme(self, self.config_obj.theme_mode) + self.startup_monitor = preferred_monitor(self, self.config_obj.monitor_bounds) + initial_geometry = self.config_obj.window_geometry or centered_geometry( + 1500, 860, self.startup_monitor + ) + self.geometry(ensure_visible_geometry(initial_geometry, self.startup_monitor)) + self._last_normal_geometry = self.geometry() + self._geometry_capture_job: str | None = None + self.bind("", self._remember_window_geometry) + self.protocol("WM_DELETE_WINDOW", self.close) + self.withdraw() + self.after_idle(self._start) + + def _start(self) -> None: + store_path = self._resolve_store() + if not store_path: + self.destroy() + return + repository = MemberRepository(store_path) + try: + SplashScreen( + self, + repository, + self._startup_complete, + self._startup_failed, + run_housekeeper=self.config_obj.run_housekeeper_on_startup, + monitor=self.startup_monitor, + housekeeper_settings=self.config_obj.housekeeper_settings(), + ) + except Exception as exc: + self._startup_failed(exc) + + def _resolve_store(self) -> Path | None: + configured = self.config_obj.store_path.strip() + if configured: + return Path(configured).expanduser() + selected = filedialog.askdirectory( + parent=self, + title="Zentrales CCMA-Mitgliederverzeichnis auswählen oder anlegen", + initialdir=str(Path.home()), + mustexist=False, + ) + if not selected: + return None + self.config_obj.store_path = selected + self.config_obj.save() + return Path(selected) + + def _startup_complete(self, result: StartupResult) -> None: + self.deiconify() + main = MainWindow( + self, + result.repository, + self.config_obj, + result.findings, + result.validation_errors, + ) + main.pack(fill="both", expand=True) + if self.config_obj.window_state in {"maximized", "zoomed"}: + self.after_idle(lambda: maximize(self)) + + def _startup_failed(self, error: Exception) -> None: + self.deiconify() + messagebox.showerror("CCMA konnte nicht gestartet werden", str(error), parent=self) + self.destroy() + + def _remember_window_geometry(self, _event: tk.Event | None = None) -> None: + if self._geometry_capture_job: + try: + self.after_cancel(self._geometry_capture_job) + except tk.TclError: + pass + self._geometry_capture_job = self.after(150, self._capture_normal_geometry) + + def _capture_normal_geometry(self) -> None: + self._geometry_capture_job = None + try: + if self.state() == "normal" and not is_maximized(self) and self.winfo_viewable(): + self._last_normal_geometry = self.winfo_geometry() + except tk.TclError: + return + + def close(self) -> None: + try: + maximized = is_maximized(self) + current_geometry = self.winfo_geometry() + if not maximized: + self._last_normal_geometry = current_geometry + self.config_obj.window_geometry = self._last_normal_geometry + self.config_obj.window_state = "maximized" if maximized else "normal" + monitor = monitor_for_geometry(self, current_geometry) + self.config_obj.monitor_bounds = monitor.as_tuple() + self.config_obj.save() + except (OSError, tk.TclError): + pass + self.destroy() + + +def main() -> None: + app = CCMAApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json new file mode 100644 index 0000000..4d52fac --- /dev/null +++ b/src/ccma/assets/CHANGELOG.json @@ -0,0 +1,25 @@ +[ + { + "version": "0.0.1-dev0", + "date": "2026-06-21", + "changes": [ + "Projektgrundlage für CCMA – Chaotic Creature Member Administration geschaffen.", + "Dateibasierten Mitglieder-Store mit transparenten JSON-Dateien, atomarem Speichern und separaten Dokumentordnern eingeführt.", + "Append-only Eventlog pro Mitglied für Systemereignisse, Zahlungen, Mahnungen und Vorstandskommentare ergänzt.", + "Universelle Mitgliedersuche, parallele Mitglied-Tabs, Dashboard und ersten prüfenden Hausmeisterlauf umgesetzt.", + "Forest Light/Dark, Material-Design-Icons und einen eigenen Splash-Screen integriert.", + "Optionen für Mitglieder-Store, zukünftige GnuCash-Datei, Darstellung und Startautomatisierung ergänzt.", + "Integrierten Changelog im Optionen-Dialog mit scrollbaren Release-Karten ergänzt.", + "Ribbon-Suche und Aktionsbuttons für ein klar getrenntes, responsives Layout überarbeitet.", + "Konfigurierbare automatische oder manuelle Mitgliedsnummern mit Pattern, Vorschau und Kollisionsschutz eingeführt.", + "Strikte Datumsvalidierung und Live-Altersanzeige für Mitgliedsdaten ergänzt.", + "Datumseingabe und -anzeige an das Systemformat angepasst; gespeichert wird weiterhin portabel im ISO-Format.", + "Eine ribbonweite Mitgliederliste mit direktem Zugriff auf alle Akten ergänzt.", + "Texthintergründe der Dashboard-Karten an die Kartenflächen angeglichen.", + "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", + "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", + "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", + "Anwendungsname und technische Paketbezeichnung auf CCMA vereinheitlicht." + ] + } +] diff --git a/src/ccma/assets/themes/forest/LICENSE b/src/ccma/assets/themes/forest/LICENSE new file mode 100644 index 0000000..0212030 --- /dev/null +++ b/src/ccma/assets/themes/forest/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 rdbende + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/ccma/assets/themes/forest/forest-dark.tcl b/src/ccma/assets/themes/forest/forest-dark.tcl new file mode 100644 index 0000000..c8aaac2 --- /dev/null +++ b/src/ccma/assets/themes/forest/forest-dark.tcl @@ -0,0 +1,534 @@ +# Copyright (c) 2021 rdbende + +# The Forest theme is a beautiful and modern ttk theme inspired by Excel. + +package require Tk 8.6 + +namespace eval ttk::theme::forest-dark { + + variable version 1.0 + package provide ttk::theme::forest-dark $version + variable colors + array set colors { + -fg "#eeeeee" + -bg "#313131" + -disabledfg "#595959" + -disabledbg "#ffffff" + -selectfg "#ffffff" + -selectbg "#217346" + } + + proc LoadImages {imgdir} { + variable I + foreach file [glob -directory $imgdir *.png] { + set img [file tail [file rootname $file]] + set I($img) [image create photo -file $file -format png] + } + } + + LoadImages [file join [file dirname [info script]] forest-dark] + + # Settings + ttk::style theme create forest-dark -parent default -settings { + ttk::style configure . \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -troughcolor $colors(-bg) \ + -focuscolor $colors(-selectbg) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -insertwidth 1 \ + -insertcolor $colors(-fg) \ + -fieldbackground $colors(-selectbg) \ + -font {TkDefaultFont 10} \ + -borderwidth 1 \ + -relief flat + + ttk::style map . -foreground [list disabled $colors(-disabledfg)] + + tk_setPalette background [ttk::style lookup . -background] \ + foreground [ttk::style lookup . -foreground] \ + highlightColor [ttk::style lookup . -focuscolor] \ + selectBackground [ttk::style lookup . -selectbackground] \ + selectForeground [ttk::style lookup . -selectforeground] \ + activeBackground [ttk::style lookup . -selectbackground] \ + activeForeground [ttk::style lookup . -selectforeground] + + option add *font [ttk::style lookup . -font] + + + # Layouts + ttk::style layout TButton { + Button.button -children { + Button.padding -children { + Button.label -side left -expand true + } + } + } + + ttk::style layout Toolbutton { + Toolbutton.button -children { + Toolbutton.padding -children { + Toolbutton.label -side left -expand true + } + } + } + + ttk::style layout TMenubutton { + Menubutton.button -children { + Menubutton.padding -children { + Menubutton.indicator -side right + Menubutton.label -side right -expand true + } + } + } + + ttk::style layout TOptionMenu { + OptionMenu.button -children { + OptionMenu.padding -children { + OptionMenu.indicator -side right + OptionMenu.label -side right -expand true + } + } + } + + ttk::style layout Accent.TButton { + AccentButton.button -children { + AccentButton.padding -children { + AccentButton.label -side left -expand true + } + } + } + + ttk::style layout TCheckbutton { + Checkbutton.button -children { + Checkbutton.padding -children { + Checkbutton.indicator -side left + Checkbutton.label -side right -expand true + } + } + } + + ttk::style layout Switch { + Switch.button -children { + Switch.padding -children { + Switch.indicator -side left + Switch.label -side right -expand true + } + } + } + + ttk::style layout ToggleButton { + ToggleButton.button -children { + ToggleButton.padding -children { + ToggleButton.label -side left -expand true + } + } + } + + ttk::style layout TRadiobutton { + Radiobutton.button -children { + Radiobutton.padding -children { + Radiobutton.indicator -side left + Radiobutton.label -side right -expand true + } + } + } + + ttk::style layout Vertical.TScrollbar { + Vertical.Scrollbar.trough -sticky ns -children { + Vertical.Scrollbar.thumb -expand true + } + } + + ttk::style layout Horizontal.TScrollbar { + Horizontal.Scrollbar.trough -sticky ew -children { + Horizontal.Scrollbar.thumb -expand true + } + } + + ttk::style layout TCombobox { + Combobox.field -sticky nswe -children { + Combobox.padding -expand true -sticky nswe -children { + Combobox.textarea -sticky nswe + } + } + Combobox.button -side right -sticky ns -children { + Combobox.arrow -sticky nsew + } + } + + ttk::style layout TSpinbox { + Spinbox.field -sticky nsew -children { + Spinbox.padding -expand true -sticky nswe -children { + Spinbox.textarea -sticky nsew + } + + } + null -side right -sticky nsew -children { + Spinbox.uparrow -side right -sticky nsew -children { + Spinbox.symuparrow + } + Spinbox.downarrow -side left -sticky nsew -children { + Spinbox.symdownarrow + } + } + } + + ttk::style layout Horizontal.TSeparator { + Horizontal.separator -sticky nswe + } + + ttk::style layout Vertical.TSeparator { + Vertical.separator -sticky nswe + } + + ttk::style layout Card { + Card.field { + Card.padding -expand 1 + } + } + + ttk::style layout TLabelframe { + Labelframe.border { + Labelframe.padding -expand 1 -children { + Labelframe.label -side left + } + } + } + + ttk::style layout TNotebook { + Notebook.border -children { + TNotebook.Tab -expand 1 -side top + Notebook.client -sticky nsew + } + } + + ttk::style layout TNotebook.Tab { + Notebook.tab -children { + Notebook.padding -side top -children { + Notebook.label + } + } + } + + ttk::style layout Treeview.Item { + Treeitem.padding -sticky nswe -children { + Treeitem.indicator -side left -sticky {} + Treeitem.image -side left -sticky {} + Treeitem.text -side left -sticky {} + } + } + + + # Elements + + # Button + ttk::style configure TButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Button.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-basic) \ + disabled $I(rect-basic) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Toolbutton + ttk::style configure Toolbutton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Toolbutton.button image \ + [list $I(empty) \ + {selected disabled} $I(empty) \ + disabled $I(empty) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-basic) \ + ] -border 4 -sticky nsew + + # Menubutton + ttk::style configure TMenubutton -padding {8 4 4 4} + + ttk::style element create Menubutton.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create Menubutton.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # OptionMenu + ttk::style configure TOptionMenu -padding {8 4 4 4} + + ttk::style element create OptionMenu.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create OptionMenu.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # AccentButton + ttk::style configure Accent.TButton -padding {8 4 8 4} -width -10 -anchor center -foreground #eeeeee + + ttk::style element create AccentButton.button image \ + [list $I(rect-accent) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-accent-hover) \ + selected $I(rect-accent) \ + pressed $I(rect-accent) \ + active $I(rect-accent-hover) \ + ] -border 4 -sticky nsew + + # Checkbutton + ttk::style configure TCheckbutton -padding 4 + + ttk::style element create Checkbutton.indicator image \ + [list $I(check-unsel-accent) \ + {alternate disabled} $I(check-tri-basic) \ + {selected disabled} $I(check-basic) \ + disabled $I(check-unsel-basic) \ + {pressed alternate} $I(check-tri-hover) \ + {active alternate} $I(check-tri-hover) \ + alternate $I(check-tri-accent) \ + {pressed selected} $I(check-hover) \ + {active selected} $I(check-hover) \ + selected $I(check-accent) \ + {pressed !selected} $I(check-unsel-pressed) \ + active $I(check-unsel-hover) \ + ] -width 26 -sticky w + + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-accent) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-accent) \ + active $I(off-hover) \ + ] -width 46 -sticky w + + # ToggleButton + ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create ToggleButton.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-basic) \ + {pressed selected} $I(rect-basic) \ + {active selected} $I(rect-accent-hover) \ + selected $I(rect-accent) \ + {pressed !selected} $I(rect-accent) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Radiobutton + ttk::style configure TRadiobutton -padding 4 + + ttk::style element create Radiobutton.indicator image \ + [list $I(radio-unsel-accent) \ + {alternate disabled} $I(radio-tri-basic) \ + {selected disabled} $I(radio-basic) \ + disabled $I(radio-unsel-basic) \ + {pressed alternate} $I(radio-tri-hover) \ + {active alternate} $I(radio-tri-hover) \ + alternate $I(radio-tri-accent) \ + {pressed selected} $I(radio-hover) \ + {active selected} $I(radio-hover) \ + selected $I(radio-accent) \ + {pressed !selected} $I(radio-unsel-pressed) \ + active $I(radio-unsel-hover) \ + ] -width 26 -sticky w + + # Scrollbar + ttk::style element create Horizontal.Scrollbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Scrollbar.thumb image \ + [list $I(hor-accent) \ + disabled $I(hor-basic) \ + pressed $I(hor-hover) \ + active $I(hor-hover) \ + ] -sticky ew + + ttk::style element create Vertical.Scrollbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Scrollbar.thumb image \ + [list $I(vert-accent) \ + disabled $I(vert-basic) \ + pressed $I(vert-hover) \ + active $I(vert-hover) \ + ] -sticky ns + + # Scale + ttk::style element create Horizontal.Scale.trough image $I(scale-hor) \ + -border 5 -padding 0 + + ttk::style element create Horizontal.Scale.slider image \ + [list $I(thumb-hor-accent) \ + disabled $I(thumb-hor-basic) \ + pressed $I(thumb-hor-hover) \ + active $I(thumb-hor-hover) \ + ] -sticky {} + + ttk::style element create Vertical.Scale.trough image $I(scale-vert) \ + -border 5 -padding 0 + + ttk::style element create Vertical.Scale.slider image \ + [list $I(thumb-vert-accent) \ + disabled $I(thumb-vert-basic) \ + pressed $I(thumb-vert-hover) \ + active $I(thumb-vert-hover) \ + ] -sticky {} + + # Progressbar + ttk::style element create Horizontal.Progressbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Progressbar.pbar image $I(hor-accent) \ + -sticky ew + + ttk::style element create Vertical.Progressbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Progressbar.pbar image $I(vert-accent) \ + -sticky ns + + # Entry + ttk::style element create Entry.field image \ + [list $I(border-basic) \ + {focus hover} $I(border-accent) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8} -sticky nsew + + # Combobox + ttk::style map TCombobox -selectbackground [list \ + {!focus} $colors(-selectbg) \ + {readonly hover} $colors(-selectbg) \ + {readonly focus} $colors(-selectbg) \ + ] + + ttk::style map TCombobox -selectforeground [list \ + {!focus} $colors(-selectfg) \ + {readonly hover} $colors(-selectfg) \ + {readonly focus} $colors(-selectfg) \ + ] + + ttk::style element create Combobox.field image \ + [list $I(border-basic) \ + {readonly disabled} $I(rect-basic) \ + {readonly pressed} $I(rect-basic) \ + {readonly focus hover} $I(rect-hover) \ + {readonly focus} $I(rect-hover) \ + {readonly hover} $I(rect-hover) \ + {focus hover} $I(border-accent) \ + readonly $I(rect-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 28 8} + + ttk::style element create Combobox.button image \ + [list $I(combo-button-basic) \ + {!readonly focus} $I(combo-button-focus) \ + {readonly focus} $I(combo-button-hover) \ + {readonly hover} $I(combo-button-hover) + ] -border 5 -padding {2 6 6 6} + + ttk::style element create Combobox.arrow image $I(down) -width 15 -sticky e + + # Spinbox + ttk::style element create Spinbox.field image \ + [list $I(border-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 54 8} -sticky nsew + + ttk::style element create Spinbox.uparrow image $I(spin-button-up) -border 4 -sticky nsew + + ttk::style element create Spinbox.downarrow image \ + [list $I(spin-button-down-basic) \ + focus $I(spin-button-down-focus) \ + ] -border 4 -sticky nsew + + ttk::style element create Spinbox.symuparrow image $I(up) -width 15 -sticky {} + ttk::style element create Spinbox.symdownarrow image $I(down) -width 17 -sticky {} + + # Sizegrip + ttk::style element create Sizegrip.sizegrip image $I(sizegrip) \ + -sticky nsew + + # Separator + ttk::style element create Horizontal.separator image $I(separator) + + ttk::style element create Vertical.separator image $I(separator) + + # Card + ttk::style element create Card.field image $I(card) \ + -border 10 -padding 4 -sticky nsew + + # Labelframe + ttk::style element create Labelframe.border image $I(card) \ + -border 5 -padding 4 -sticky nsew + + # Notebook + ttk::style configure TNotebook -padding 2 + + ttk::style element create Notebook.border image $I(card) -border 5 + + ttk::style element create Notebook.client image $I(notebook) -border 5 + + ttk::style element create Notebook.tab image \ + [list $I(tab-basic) \ + selected $I(tab-accent) \ + active $I(tab-hover) \ + ] -border 5 -padding {14 4} + + # Treeview + ttk::style element create Treeview.field image $I(card) \ + -border 5 + + ttk::style element create Treeheading.cell image \ + [list $I(tree-basic) \ + pressed $I(tree-pressed) + ] -border 5 -padding 6 -sticky nsew + + ttk::style element create Treeitem.indicator image \ + [list $I(right) \ + user2 $I(empty) \ + user1 $I(down) \ + ] -width 17 -sticky {} + + ttk::style configure Treeview -background $colors(-bg) + ttk::style configure Treeview.Item -padding {2 0 0 0} + + ttk::style map Treeview \ + -background [list selected $colors(-selectbg)] \ + -foreground [list selected $colors(-selectfg)] + + # Sashes + #ttk::style map TPanedwindow -background [list hover $colors(-bg)] + } +} diff --git a/src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png b/src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png new file mode 100644 index 0000000..9e6cc8e Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/border-accent.png b/src/ccma/assets/themes/forest/forest-dark/border-accent.png new file mode 100644 index 0000000..1f7fc27 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/border-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/border-basic.png b/src/ccma/assets/themes/forest/forest-dark/border-basic.png new file mode 100644 index 0000000..a483271 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/border-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/border-hover.png b/src/ccma/assets/themes/forest/forest-dark/border-hover.png new file mode 100644 index 0000000..dcd837a Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/border-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/border-invalid.png b/src/ccma/assets/themes/forest/forest-dark/border-invalid.png new file mode 100644 index 0000000..63cdd6e Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/border-invalid.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/card.png b/src/ccma/assets/themes/forest/forest-dark/card.png new file mode 100644 index 0000000..3ac8413 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/card.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-accent.png b/src/ccma/assets/themes/forest/forest-dark/check-accent.png new file mode 100644 index 0000000..81f4a62 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-basic.png new file mode 100644 index 0000000..dd93bbc Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-hover.png new file mode 100644 index 0000000..6a90056 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png new file mode 100644 index 0000000..4a49300 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png new file mode 100644 index 0000000..219b92d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png new file mode 100644 index 0000000..ee9d108 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-accent.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-accent.png new file mode 100644 index 0000000..abbdeb8 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-unsel-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png new file mode 100644 index 0000000..a483271 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png new file mode 100644 index 0000000..da35159 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png new file mode 100644 index 0000000..d7a8825 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png new file mode 100644 index 0000000..7582f0e Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png new file mode 100644 index 0000000..50dba42 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png new file mode 100644 index 0000000..555d685 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/down.png b/src/ccma/assets/themes/forest/forest-dark/down.png new file mode 100644 index 0000000..8dbdd89 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/down.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/empty.png b/src/ccma/assets/themes/forest/forest-dark/empty.png new file mode 100644 index 0000000..202e3de Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/empty.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-accent.png b/src/ccma/assets/themes/forest/forest-dark/hor-accent.png new file mode 100644 index 0000000..b471f4b Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/hor-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-basic.png b/src/ccma/assets/themes/forest/forest-dark/hor-basic.png new file mode 100644 index 0000000..9a73a59 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/hor-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-hover.png b/src/ccma/assets/themes/forest/forest-dark/hor-hover.png new file mode 100644 index 0000000..2f8b196 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/hor-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/notebook.png b/src/ccma/assets/themes/forest/forest-dark/notebook.png new file mode 100644 index 0000000..edffcb5 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/notebook.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/off-accent.png b/src/ccma/assets/themes/forest/forest-dark/off-accent.png new file mode 100644 index 0000000..8940a8c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/off-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/off-basic.png b/src/ccma/assets/themes/forest/forest-dark/off-basic.png new file mode 100644 index 0000000..43dd748 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/off-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/off-hover.png b/src/ccma/assets/themes/forest/forest-dark/off-hover.png new file mode 100644 index 0000000..3d5f8a2 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/off-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/on-accent.png b/src/ccma/assets/themes/forest/forest-dark/on-accent.png new file mode 100644 index 0000000..1405001 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/on-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/on-basic.png b/src/ccma/assets/themes/forest/forest-dark/on-basic.png new file mode 100644 index 0000000..8db29a9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/on-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/on-hover.png b/src/ccma/assets/themes/forest/forest-dark/on-hover.png new file mode 100644 index 0000000..7ad670d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/on-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-accent.png new file mode 100644 index 0000000..099e148 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-basic.png b/src/ccma/assets/themes/forest/forest-dark/radio-basic.png new file mode 100644 index 0000000..6b745d1 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-hover.png b/src/ccma/assets/themes/forest/forest-dark/radio-hover.png new file mode 100644 index 0000000..e2fa366 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png new file mode 100644 index 0000000..756ff13 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png b/src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png new file mode 100644 index 0000000..0f20b21 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-tri-hover.png b/src/ccma/assets/themes/forest/forest-dark/radio-tri-hover.png new file mode 100644 index 0000000..2af0b72 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-tri-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png new file mode 100644 index 0000000..b8a2f95 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-basic.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-basic.png new file mode 100644 index 0000000..bd8a723 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-hover.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-hover.png new file mode 100644 index 0000000..6512106 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png new file mode 100644 index 0000000..493f02d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png b/src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png new file mode 100644 index 0000000..d7a8825 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-accent.png b/src/ccma/assets/themes/forest/forest-dark/rect-accent.png new file mode 100644 index 0000000..bebf948 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/rect-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-basic.png b/src/ccma/assets/themes/forest/forest-dark/rect-basic.png new file mode 100644 index 0000000..c6474d5 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/rect-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-hover.png b/src/ccma/assets/themes/forest/forest-dark/rect-hover.png new file mode 100644 index 0000000..b669407 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/rect-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/right.png b/src/ccma/assets/themes/forest/forest-dark/right.png new file mode 100644 index 0000000..336945c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/right.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/scale-hor.png b/src/ccma/assets/themes/forest/forest-dark/scale-hor.png new file mode 100644 index 0000000..37172d0 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/scale-hor.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/scale-vert.png b/src/ccma/assets/themes/forest/forest-dark/scale-vert.png new file mode 100644 index 0000000..f268b60 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/scale-vert.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/separator.png b/src/ccma/assets/themes/forest/forest-dark/separator.png new file mode 100644 index 0000000..2b2a001 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/separator.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/sizegrip.png b/src/ccma/assets/themes/forest/forest-dark/sizegrip.png new file mode 100644 index 0000000..5bfc967 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/sizegrip.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png b/src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png new file mode 100644 index 0000000..f4e0890 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/spin-button-down-focus.png b/src/ccma/assets/themes/forest/forest-dark/spin-button-down-focus.png new file mode 100644 index 0000000..9421a2c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/spin-button-down-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/spin-button-up.png b/src/ccma/assets/themes/forest/forest-dark/spin-button-up.png new file mode 100644 index 0000000..6e87841 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/spin-button-up.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/tab-accent.png b/src/ccma/assets/themes/forest/forest-dark/tab-accent.png new file mode 100644 index 0000000..ffdf1a0 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/tab-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/tab-basic.png b/src/ccma/assets/themes/forest/forest-dark/tab-basic.png new file mode 100644 index 0000000..14d222c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/tab-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/tab-hover.png b/src/ccma/assets/themes/forest/forest-dark/tab-hover.png new file mode 100644 index 0000000..31e74d2 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/tab-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-hor-accent.png b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-accent.png new file mode 100644 index 0000000..477a11f Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png new file mode 100644 index 0000000..1426d8d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-hor-hover.png b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-hover.png new file mode 100644 index 0000000..18db753 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png new file mode 100644 index 0000000..3db6b23 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png new file mode 100644 index 0000000..b1a5587 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png new file mode 100644 index 0000000..6137ff1 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/tree-basic.png b/src/ccma/assets/themes/forest/forest-dark/tree-basic.png new file mode 100644 index 0000000..06e9b18 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/tree-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/tree-pressed.png b/src/ccma/assets/themes/forest/forest-dark/tree-pressed.png new file mode 100644 index 0000000..728c69a Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/tree-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/up.png b/src/ccma/assets/themes/forest/forest-dark/up.png new file mode 100644 index 0000000..b02eda4 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/up.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/vert-accent.png b/src/ccma/assets/themes/forest/forest-dark/vert-accent.png new file mode 100644 index 0000000..305ee72 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/vert-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/vert-basic.png b/src/ccma/assets/themes/forest/forest-dark/vert-basic.png new file mode 100644 index 0000000..36988fd Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/vert-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-dark/vert-hover.png b/src/ccma/assets/themes/forest/forest-dark/vert-hover.png new file mode 100644 index 0000000..c2259c7 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-dark/vert-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light.tcl b/src/ccma/assets/themes/forest/forest-light.tcl new file mode 100644 index 0000000..1abb62d --- /dev/null +++ b/src/ccma/assets/themes/forest/forest-light.tcl @@ -0,0 +1,541 @@ +# Copyright (c) 2021 rdbende + +# The Forest theme is a beautiful and modern ttk theme inspired by Excel. + +package require Tk 8.6 + +namespace eval ttk::theme::forest-light { + + variable version 1.0 + package provide ttk::theme::forest-light $version + variable colors + array set colors { + -fg "#313131" + -bg "#ffffff" + -disabledfg "#595959" + -disabledbg "#ffffff" + -selectfg "#ffffff" + -selectbg "#217346" + } + + proc LoadImages {imgdir} { + variable I + foreach file [glob -directory $imgdir *.png] { + set img [file tail [file rootname $file]] + set I($img) [image create photo -file $file -format png] + } + } + + LoadImages [file join [file dirname [info script]] forest-light] + + # Settings + ttk::style theme create forest-light -parent default -settings { + ttk::style configure . \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -troughcolor $colors(-bg) \ + -focuscolor $colors(-selectbg) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -insertwidth 1 \ + -insertcolor $colors(-fg) \ + -fieldbackground $colors(-selectbg) \ + -font {TkDefaultFont 10} \ + -borderwidth 1 \ + -relief flat + + ttk::style map . -foreground [list disabled $colors(-disabledfg)] + + tk_setPalette background [ttk::style lookup . -background] \ + foreground [ttk::style lookup . -foreground] \ + highlightColor [ttk::style lookup . -focuscolor] \ + selectBackground [ttk::style lookup . -selectbackground] \ + selectForeground [ttk::style lookup . -selectforeground] \ + activeBackground [ttk::style lookup . -selectbackground] \ + activeForeground [ttk::style lookup . -selectforeground] + + option add *font [ttk::style lookup . -font] + + + # Layouts + ttk::style layout TButton { + Button.button -children { + Button.padding -children { + Button.label -side left -expand true + } + } + } + + ttk::style layout Toolbutton { + Toolbutton.button -children { + Toolbutton.padding -children { + Toolbutton.label -side left -expand true + } + } + } + + ttk::style layout TMenubutton { + Menubutton.button -children { + Menubutton.padding -children { + Menubutton.indicator -side right + Menubutton.label -side right -expand true + } + } + } + + ttk::style layout TOptionMenu { + OptionMenu.button -children { + OptionMenu.padding -children { + OptionMenu.indicator -side right + OptionMenu.label -side right -expand true + } + } + } + + ttk::style layout Accent.TButton { + AccentButton.button -children { + AccentButton.padding -children { + AccentButton.label -side left -expand true + } + } + } + + ttk::style layout TCheckbutton { + Checkbutton.button -children { + Checkbutton.padding -children { + Checkbutton.indicator -side left + Checkbutton.label -side right -expand true + } + } + } + + ttk::style layout Switch { + Switch.button -children { + Switch.padding -children { + Switch.indicator -side left + Switch.label -side right -expand true + } + } + } + + ttk::style layout ToggleButton { + ToggleButton.button -children { + ToggleButton.padding -children { + ToggleButton.label -side left -expand true + } + } + } + + ttk::style layout TRadiobutton { + Radiobutton.button -children { + Radiobutton.padding -children { + Radiobutton.indicator -side left + Radiobutton.label -side right -expand true + } + } + } + + ttk::style layout Vertical.TScrollbar { + Vertical.Scrollbar.trough -sticky ns -children { + Vertical.Scrollbar.thumb -expand true + } + } + + ttk::style layout Horizontal.TScrollbar { + Horizontal.Scrollbar.trough -sticky ew -children { + Horizontal.Scrollbar.thumb -expand true + } + } + + ttk::style layout TCombobox { + Combobox.field -sticky nswe -children { + Combobox.padding -expand true -sticky nswe -children { + Combobox.textarea -sticky nswe + } + } + Combobox.button -side right -sticky ns -children { + Combobox.arrow -sticky nsew + } + } + + ttk::style layout TSpinbox { + Spinbox.field -sticky nsew -children { + Spinbox.padding -expand true -sticky nswe -children { + Spinbox.textarea -sticky nsew + } + + } + null -side right -sticky nsew -children { + Spinbox.uparrow -side right -sticky nsew -children { + Spinbox.symuparrow + } + Spinbox.downarrow -side left -sticky nsew -children { + Spinbox.symdownarrow + } + } + } + + ttk::style layout Horizontal.TSeparator { + Horizontal.separator -sticky nswe + } + + ttk::style layout Vertical.TSeparator { + Vertical.separator -sticky nswe + } + + ttk::style layout Card { + Card.field { + Card.padding -expand 1 + } + } + + ttk::style layout TLabelframe { + Labelframe.border { + Labelframe.padding -expand 1 -children { + Labelframe.label -side left + } + } + } + + ttk::style layout TNotebook { + Notebook.border -children { + TNotebook.Tab -expand 1 -side top + Notebook.client -sticky nsew + } + } + + ttk::style layout TNotebook.Tab { + Notebook.tab -children { + Notebook.padding -side top -children { + Notebook.label + } + } + } + + ttk::style layout Treeview.Item { + Treeitem.padding -sticky nswe -children { + Treeitem.indicator -side left -sticky {} + Treeitem.image -side left -sticky {} + Treeitem.text -side left -sticky {} + } + } + + + # Elements + + # Button + ttk::style configure TButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Button.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-basic) \ + disabled $I(rect-basic) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Toolbutton + ttk::style configure Toolbutton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Toolbutton.button image \ + [list $I(empty) \ + {selected disabled} $I(empty) \ + disabled $I(empty) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-basic) \ + ] -border 4 -sticky nsew + + # Menubutton + ttk::style configure TMenubutton -padding {8 4 4 4} + + ttk::style element create Menubutton.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create Menubutton.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # OptionMenu + ttk::style configure TOptionMenu -padding {8 4 4 4} + + ttk::style element create OptionMenu.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create OptionMenu.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # AccentButton + ttk::style configure Accent.TButton -padding {8 4 8 4} -width -10 -anchor center -foreground #eeeeee + + ttk::style element create AccentButton.button image \ + [list $I(rect-accent) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-accent-hover) \ + selected $I(rect-accent) \ + pressed $I(rect-accent) \ + active $I(rect-accent-hover) \ + ] -border 4 -sticky nsew + + # Checkbutton + ttk::style configure TCheckbutton -padding 4 + + ttk::style element create Checkbutton.indicator image \ + [list $I(check-unsel-accent) \ + {alternate disabled} $I(check-tri-basic) \ + {selected disabled} $I(check-basic) \ + disabled $I(check-unsel-basic) \ + {pressed alternate} $I(check-tri-hover) \ + {active alternate} $I(check-tri-hover) \ + alternate $I(check-tri-accent) \ + {pressed selected} $I(check-hover) \ + {active selected} $I(check-hover) \ + selected $I(check-accent) \ + {pressed !selected} $I(check-unsel-pressed) \ + active $I(check-unsel-hover) \ + ] -width 26 -sticky w + + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-accent) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-accent) \ + active $I(off-hover) \ + ] -width 46 -sticky w + + # ToggleButton + ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center -foregound $colors(-fg) + + ttk::style map ToggleButton -foreground \ + [list {pressed selected} $colors(-fg) \ + {pressed !selected} #ffffff \ + selected #ffffff] + + ttk::style element create ToggleButton.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-basic) \ + {pressed selected} $I(rect-basic) \ + {active selected} $I(rect-accent-hover) \ + selected $I(rect-accent) \ + {pressed !selected} $I(rect-accent) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Radiobutton + ttk::style configure TRadiobutton -padding 4 + + ttk::style element create Radiobutton.indicator image \ + [list $I(radio-unsel-accent) \ + {alternate disabled} $I(radio-tri-basic) \ + {selected disabled} $I(radio-basic) \ + disabled $I(radio-unsel-basic) \ + {pressed alternate} $I(radio-tri-hover) \ + {active alternate} $I(radio-tri-hover) \ + alternate $I(radio-tri-accent) \ + {pressed selected} $I(radio-hover) \ + {active selected} $I(radio-hover) \ + selected $I(radio-accent) \ + {pressed !selected} $I(radio-unsel-pressed) \ + active $I(radio-unsel-hover) \ + ] -width 26 -sticky w + + # Scrollbar + ttk::style element create Horizontal.Scrollbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Scrollbar.thumb image \ + [list $I(hor-accent) \ + disabled $I(hor-basic) \ + pressed $I(hor-hover) \ + active $I(hor-hover) \ + ] -sticky ew + + ttk::style element create Vertical.Scrollbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Scrollbar.thumb image \ + [list $I(vert-accent) \ + disabled $I(vert-basic) \ + pressed $I(vert-hover) \ + active $I(vert-hover) \ + ] -sticky ns + + # Scale + ttk::style element create Horizontal.Scale.trough image $I(scale-hor) \ + -border 5 -padding 0 + + ttk::style element create Horizontal.Scale.slider image \ + [list $I(thumb-hor-accent) \ + disabled $I(thumb-hor-basic) \ + pressed $I(thumb-hor-hover) \ + active $I(thumb-hor-hover) \ + ] -sticky {} + + ttk::style element create Vertical.Scale.trough image $I(scale-vert) \ + -border 5 -padding 0 + + ttk::style element create Vertical.Scale.slider image \ + [list $I(thumb-vert-accent) \ + disabled $I(thumb-vert-basic) \ + pressed $I(thumb-vert-hover) \ + active $I(thumb-vert-hover) \ + ] -sticky {} + + # Progressbar + ttk::style element create Horizontal.Progressbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Progressbar.pbar image $I(hor-accent) \ + -sticky ew + + ttk::style element create Vertical.Progressbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Progressbar.pbar image $I(vert-accent) \ + -sticky ns + + # Entry + ttk::style element create Entry.field image \ + [list $I(border-basic) \ + {focus hover} $I(border-accent) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8} -sticky nsew + + # Combobox + ttk::style map TCombobox -selectbackground [list \ + {!focus} $colors(-selectbg) \ + {readonly hover} $colors(-selectbg) \ + {readonly focus} $colors(-selectbg) \ + ] + + ttk::style map TCombobox -selectforeground [list \ + {!focus} $colors(-selectfg) \ + {readonly hover} $colors(-selectfg) \ + {readonly focus} $colors(-selectfg) \ + ] + + ttk::style element create Combobox.field image \ + [list $I(border-basic) \ + {readonly disabled} $I(rect-basic) \ + {readonly pressed} $I(rect-basic) \ + {readonly focus hover} $I(rect-hover) \ + {readonly focus} $I(rect-hover) \ + {readonly hover} $I(rect-hover) \ + {focus hover} $I(border-accent) \ + readonly $I(rect-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 28 8} + + ttk::style element create Combobox.button image \ + [list $I(combo-button-basic) \ + {!readonly focus} $I(combo-button-focus) \ + {readonly focus} $I(combo-button-hover) \ + {readonly hover} $I(combo-button-hover) + ] -border 5 -padding {2 6 6 6} + + ttk::style element create Combobox.arrow image $I(down) -width 15 -sticky e + + # Spinbox + ttk::style element create Spinbox.field image \ + [list $I(border-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 54 8} -sticky nsew + + ttk::style element create Spinbox.uparrow image $I(spin-button-up) -border 4 -sticky nsew + + ttk::style element create Spinbox.downarrow image \ + [list $I(spin-button-down-basic) \ + focus $I(spin-button-down-focus) \ + ] -border 4 -sticky nsew + + ttk::style element create Spinbox.symuparrow image $I(up) -width 15 -sticky {} + ttk::style element create Spinbox.symdownarrow image $I(down) -width 17 -sticky {} + + # Sizegrip + ttk::style element create Sizegrip.sizegrip image $I(sizegrip) \ + -sticky nsew + + # Separator + ttk::style element create Horizontal.separator image $I(separator) + + ttk::style element create Vertical.separator image $I(separator) + + # Card + ttk::style element create Card.field image $I(card) \ + -border 10 -padding 4 -sticky nsew + + # Labelframe + ttk::style element create Labelframe.border image $I(card) \ + -border 5 -padding 4 -sticky nsew + + # Notebook + ttk::style configure TNotebook -padding 2 + + ttk::style element create Notebook.border image $I(card) -border 5 + + ttk::style element create Notebook.client image $I(notebook) -border 5 + + ttk::style element create Notebook.tab image \ + [list $I(tab-basic) \ + selected $I(tab-accent) \ + active $I(tab-hover) \ + ] -border 5 -padding {14 4} + + # Treeview + ttk::style element create Treeview.field image $I(card) \ + -border 5 + + ttk::style element create Treeheading.cell image \ + [list $I(tree-basic) \ + pressed $I(tree-pressed) + ] -border 5 -padding 6 -sticky nsew + + ttk::style element create Treeitem.indicator image \ + [list $I(right) \ + user2 $I(empty) \ + {user1 focus} $I(down-focus) \ + focus $I(right-focus) \ + user1 $I(down) \ + ] -width 17 -sticky {} + + ttk::style configure Treeview -background $colors(-bg) + ttk::style configure Treeview.Item -padding {2 0 0 0} + + ttk::style map Treeview \ + -background [list selected $colors(-selectbg)] \ + -foreground [list selected $colors(-selectfg)] + + # Sashes + #ttk::style map TPanedwindow -background [list hover $colors(-bg)] + } +} diff --git a/src/ccma/assets/themes/forest/forest-light/border-accent-hover.png b/src/ccma/assets/themes/forest/forest-light/border-accent-hover.png new file mode 100644 index 0000000..72c3e60 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/border-accent-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/border-accent.png b/src/ccma/assets/themes/forest/forest-light/border-accent.png new file mode 100644 index 0000000..24ea0b6 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/border-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/border-basic.png b/src/ccma/assets/themes/forest/forest-light/border-basic.png new file mode 100644 index 0000000..1094b6d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/border-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/border-hover.png b/src/ccma/assets/themes/forest/forest-light/border-hover.png new file mode 100644 index 0000000..a09fb3b Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/border-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/border-invalid.png b/src/ccma/assets/themes/forest/forest-light/border-invalid.png new file mode 100644 index 0000000..2d01fa5 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/border-invalid.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/card.png b/src/ccma/assets/themes/forest/forest-light/card.png new file mode 100644 index 0000000..fc62212 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/card.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-accent.png b/src/ccma/assets/themes/forest/forest-light/check-accent.png new file mode 100644 index 0000000..a789b5d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-basic.png b/src/ccma/assets/themes/forest/forest-light/check-basic.png new file mode 100644 index 0000000..198019e Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-hover.png b/src/ccma/assets/themes/forest/forest-light/check-hover.png new file mode 100644 index 0000000..0284dba Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-accent.png b/src/ccma/assets/themes/forest/forest-light/check-tri-accent.png new file mode 100644 index 0000000..27bcb36 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-tri-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-basic.png b/src/ccma/assets/themes/forest/forest-light/check-tri-basic.png new file mode 100644 index 0000000..a897099 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-tri-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-hover.png b/src/ccma/assets/themes/forest/forest-light/check-tri-hover.png new file mode 100644 index 0000000..f38f8b9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-tri-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-accent.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-accent.png new file mode 100644 index 0000000..2dd9e0f Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-unsel-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-basic.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-basic.png new file mode 100644 index 0000000..1094b6d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-unsel-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png new file mode 100644 index 0000000..1690b97 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png new file mode 100644 index 0000000..2f4a5a8 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/combo-button-basic.png b/src/ccma/assets/themes/forest/forest-light/combo-button-basic.png new file mode 100644 index 0000000..b7daa04 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/combo-button-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/combo-button-focus.png b/src/ccma/assets/themes/forest/forest-light/combo-button-focus.png new file mode 100644 index 0000000..179c742 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/combo-button-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/combo-button-hover.png b/src/ccma/assets/themes/forest/forest-light/combo-button-hover.png new file mode 100644 index 0000000..297c88b Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/combo-button-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/down-focus.png b/src/ccma/assets/themes/forest/forest-light/down-focus.png new file mode 100644 index 0000000..70921a3 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/down-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/down.png b/src/ccma/assets/themes/forest/forest-light/down.png new file mode 100644 index 0000000..28e9d24 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/down.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/empty.png b/src/ccma/assets/themes/forest/forest-light/empty.png new file mode 100644 index 0000000..202e3de Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/empty.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/hor-accent.png b/src/ccma/assets/themes/forest/forest-light/hor-accent.png new file mode 100644 index 0000000..e92bc7d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/hor-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/hor-basic.png b/src/ccma/assets/themes/forest/forest-light/hor-basic.png new file mode 100644 index 0000000..eb18d1e Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/hor-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/hor-hover.png b/src/ccma/assets/themes/forest/forest-light/hor-hover.png new file mode 100644 index 0000000..92bc070 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/hor-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/notebook.png b/src/ccma/assets/themes/forest/forest-light/notebook.png new file mode 100644 index 0000000..5be4294 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/notebook.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/off-accent.png b/src/ccma/assets/themes/forest/forest-light/off-accent.png new file mode 100644 index 0000000..6263d96 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/off-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/off-basic.png b/src/ccma/assets/themes/forest/forest-light/off-basic.png new file mode 100644 index 0000000..9e60b33 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/off-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/off-hover.png b/src/ccma/assets/themes/forest/forest-light/off-hover.png new file mode 100644 index 0000000..5c92e93 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/off-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/on-accent.png b/src/ccma/assets/themes/forest/forest-light/on-accent.png new file mode 100644 index 0000000..c191ac5 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/on-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/on-basic.png b/src/ccma/assets/themes/forest/forest-light/on-basic.png new file mode 100644 index 0000000..fd0f6c0 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/on-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/on-hover.png b/src/ccma/assets/themes/forest/forest-light/on-hover.png new file mode 100644 index 0000000..df18787 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/on-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-accent.png b/src/ccma/assets/themes/forest/forest-light/radio-accent.png new file mode 100644 index 0000000..944ed85 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-basic.png b/src/ccma/assets/themes/forest/forest-light/radio-basic.png new file mode 100644 index 0000000..8f93029 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-hover.png b/src/ccma/assets/themes/forest/forest-light/radio-hover.png new file mode 100644 index 0000000..0e9f2bd Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png b/src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png new file mode 100644 index 0000000..5a5b54c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-tri-basic.png b/src/ccma/assets/themes/forest/forest-light/radio-tri-basic.png new file mode 100644 index 0000000..654f7e9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-tri-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-tri-hover.png b/src/ccma/assets/themes/forest/forest-light/radio-tri-hover.png new file mode 100644 index 0000000..12d2a9a Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-tri-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png new file mode 100644 index 0000000..fdfe1f0 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-basic.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-basic.png new file mode 100644 index 0000000..6c88725 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-unsel-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-hover.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-hover.png new file mode 100644 index 0000000..de5afe9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-unsel-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png new file mode 100644 index 0000000..7428dc9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/rect-accent-hover.png b/src/ccma/assets/themes/forest/forest-light/rect-accent-hover.png new file mode 100644 index 0000000..2f4a5a8 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/rect-accent-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/rect-accent.png b/src/ccma/assets/themes/forest/forest-light/rect-accent.png new file mode 100644 index 0000000..6f5b8f4 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/rect-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/rect-basic.png b/src/ccma/assets/themes/forest/forest-light/rect-basic.png new file mode 100644 index 0000000..4fbd3c5 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/rect-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/rect-hover.png b/src/ccma/assets/themes/forest/forest-light/rect-hover.png new file mode 100644 index 0000000..2fc43f6 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/rect-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/right-focus.png b/src/ccma/assets/themes/forest/forest-light/right-focus.png new file mode 100644 index 0000000..4ca8ed7 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/right-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/right.png b/src/ccma/assets/themes/forest/forest-light/right.png new file mode 100644 index 0000000..cba4328 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/right.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/scale-hor.png b/src/ccma/assets/themes/forest/forest-light/scale-hor.png new file mode 100644 index 0000000..86e2fcb Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/scale-hor.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/scale-vert.png b/src/ccma/assets/themes/forest/forest-light/scale-vert.png new file mode 100644 index 0000000..0531242 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/scale-vert.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/separator.png b/src/ccma/assets/themes/forest/forest-light/separator.png new file mode 100644 index 0000000..fe323b8 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/separator.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/sizegrip.png b/src/ccma/assets/themes/forest/forest-light/sizegrip.png new file mode 100644 index 0000000..8f0bddf Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/sizegrip.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png b/src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png new file mode 100644 index 0000000..8153581 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png b/src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png new file mode 100644 index 0000000..544545c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-up.png b/src/ccma/assets/themes/forest/forest-light/spin-button-up.png new file mode 100644 index 0000000..e04e757 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/spin-button-up.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/tab-accent.png b/src/ccma/assets/themes/forest/forest-light/tab-accent.png new file mode 100644 index 0000000..37b37c4 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/tab-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/tab-basic.png b/src/ccma/assets/themes/forest/forest-light/tab-basic.png new file mode 100644 index 0000000..860417c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/tab-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/tab-hover.png b/src/ccma/assets/themes/forest/forest-light/tab-hover.png new file mode 100644 index 0000000..5d6277c Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/tab-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png b/src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png new file mode 100644 index 0000000..5179d9d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png b/src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png new file mode 100644 index 0000000..ba727d7 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-hor-hover.png b/src/ccma/assets/themes/forest/forest-light/thumb-hor-hover.png new file mode 100644 index 0000000..9fee17d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-hor-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png new file mode 100644 index 0000000..f793ad9 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png new file mode 100644 index 0000000..a58440d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png new file mode 100644 index 0000000..faa6f6b Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/tree-basic.png b/src/ccma/assets/themes/forest/forest-light/tree-basic.png new file mode 100644 index 0000000..98c26e0 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/tree-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/tree-pressed.png b/src/ccma/assets/themes/forest/forest-light/tree-pressed.png new file mode 100644 index 0000000..517cc19 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/tree-pressed.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/up.png b/src/ccma/assets/themes/forest/forest-light/up.png new file mode 100644 index 0000000..196e038 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/up.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/vert-accent.png b/src/ccma/assets/themes/forest/forest-light/vert-accent.png new file mode 100644 index 0000000..7dab874 Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/vert-accent.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/vert-basic.png b/src/ccma/assets/themes/forest/forest-light/vert-basic.png new file mode 100644 index 0000000..d5f61ec Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/vert-basic.png differ diff --git a/src/ccma/assets/themes/forest/forest-light/vert-hover.png b/src/ccma/assets/themes/forest/forest-light/vert-hover.png new file mode 100644 index 0000000..bd8957d Binary files /dev/null and b/src/ccma/assets/themes/forest/forest-light/vert-hover.png differ diff --git a/src/ccma/config.py b/src/ccma/config.py new file mode 100644 index 0000000..97cd80a --- /dev/null +++ b/src/ccma/config.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from ccma.storage.atomic import write_json_atomic + +if TYPE_CHECKING: + from ccma.services.housekeeper import HousekeeperSettings + + +@dataclass(slots=True) +class AppConfig: + store_path: str = "" + gnucash_path: str = "" + theme_mode: str = "dark" + run_housekeeper_on_startup: bool = True + birthday_days_before: int = 7 + birthday_days_after: int = 2 + anniversary_days_before: int = 14 + anniversary_days_after: int = 7 + anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y" + window_geometry: str = "" + window_state: str = "normal" + monitor_bounds: tuple[int, int, int, int] | None = None + + @property + def path(self) -> Path: + return config_directory() / "config.json" + + def save(self) -> None: + write_json_atomic( + self.path, + { + "schema_version": 1, + "store_path": self.store_path, + "gnucash_path": self.gnucash_path, + "theme_mode": self.theme_mode, + "run_housekeeper_on_startup": self.run_housekeeper_on_startup, + "birthday_days_before": self.birthday_days_before, + "birthday_days_after": self.birthday_days_after, + "anniversary_days_before": self.anniversary_days_before, + "anniversary_days_after": self.anniversary_days_after, + "anniversary_intervals": self.anniversary_intervals, + "window_geometry": self.window_geometry, + "window_state": self.window_state, + "monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None, + }, + ) + + def housekeeper_settings(self) -> HousekeeperSettings: + from ccma.services.housekeeper import HousekeeperSettings + from ccma.services.intervals import IntervalValidationError + + try: + return HousekeeperSettings.from_values( + birthday_days_before=self.birthday_days_before, + birthday_days_after=self.birthday_days_after, + anniversary_days_before=self.anniversary_days_before, + anniversary_days_after=self.anniversary_days_after, + anniversary_intervals=self.anniversary_intervals, + ) + except IntervalValidationError: + return HousekeeperSettings() + + +def config_directory() -> Path: + override = os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR") + if override: + return Path(override).expanduser() + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home())) / "CCMA" + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "ccma" + + +def load_config() -> AppConfig: + path = config_directory() / "config.json" + if not path.exists() and not (os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR")): + legacy_path = _legacy_config_directory() / "config.json" + if legacy_path.exists(): + path = legacy_path + store_override = os.environ.get("CCMA_STORE") or os.environ.get("C3MA_STORE", "") + if not path.exists(): + return AppConfig(store_path=store_override) + try: + data = json.loads(path.read_text(encoding="utf-8")) + monitor_raw = data.get("monitor_bounds") + monitor_bounds = None + if isinstance(monitor_raw, list) and len(monitor_raw) == 4: + monitor_bounds = tuple(int(value) for value in monitor_raw) + return AppConfig( + store_path=store_override or str(data.get("store_path", "")), + gnucash_path=str(data.get("gnucash_path", "")), + theme_mode=str(data.get("theme_mode", "dark")), + run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)), + birthday_days_before=int(data.get("birthday_days_before", 7)), + birthday_days_after=int(data.get("birthday_days_after", 2)), + anniversary_days_before=int(data.get("anniversary_days_before", 14)), + anniversary_days_after=int(data.get("anniversary_days_after", 7)), + anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")), + window_geometry=str(data.get("window_geometry", "")), + window_state=str(data.get("window_state", "normal")), + monitor_bounds=monitor_bounds, + ) + except (OSError, ValueError, TypeError): + return AppConfig(store_path=store_override) + + +def _legacy_config_directory() -> Path: + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home())) / "C3MA" + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "c3ma" diff --git a/src/ccma/domain/__init__.py b/src/ccma/domain/__init__.py new file mode 100644 index 0000000..1ac8bec --- /dev/null +++ b/src/ccma/domain/__init__.py @@ -0,0 +1,3 @@ +from ccma.domain.models import ContributionData, Event, Member + +__all__ = ["ContributionData", "Event", "Member"] diff --git a/src/ccma/domain/dates.py b/src/ccma/domain/dates.py new file mode 100644 index 0000000..97097cc --- /dev/null +++ b/src/ccma/domain/dates.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import locale +import os +import re +from datetime import date, datetime + + +class DateValidationError(ValueError): + pass + + +def setup_system_locale() -> None: + try: + locale.setlocale(locale.LC_TIME, "") + except locale.Error: + pass + + +def system_date_pattern() -> str: + try: + system_pattern = locale.nl_langinfo(locale.D_FMT) + except (AttributeError, ValueError): + system_pattern = "" + year_position = system_pattern.find("%Y") + day_position = system_pattern.find("%d") + month_position = system_pattern.find("%m") + if ( + year_position >= 0 + and day_position >= 0 + and month_position >= 0 + and year_position < min(day_position, month_position) + ): + return "%Y-%m-%d" + if day_position >= 0 and month_position >= 0 and day_position < month_position: + return "%d.%m.%Y" + locale_hint = " ".join( + filter( + None, + ( + os.environ.get("LC_TIME"), + os.environ.get("LANGUAGE"), + os.environ.get("LANG"), + ), + ) + ).lower() + day_first_languages = ("de", "at", "ch", "fr", "it", "es", "pt", "nl", "pl", "cs") + return "%d.%m.%Y" if locale_hint.startswith(day_first_languages) else "%Y-%m-%d" + + +def date_input_hint() -> str: + return "DD.MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM-DD" + + +def parse_date_input(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: + text = value.strip() + if not text: + if allow_empty: + return None + raise DateValidationError(f"{field_name} ist erforderlich.") + patterns = (system_date_pattern(), "%Y-%m-%d", "%d.%m.%Y") + for pattern in dict.fromkeys(patterns): + expected = r"\d{2}\.\d{2}\.\d{4}" if pattern == "%d.%m.%Y" else r"\d{4}-\d{2}-\d{2}" + if not re.fullmatch(expected, text): + continue + try: + return datetime.strptime(text, pattern).date() + except ValueError: + continue + formats = date_input_hint() + if formats != "YYYY-MM-DD": + formats += " oder YYYY-MM-DD" + raise DateValidationError(f"{field_name} muss ein gültiges Datum im Format {formats} sein.") + + +def normalize_date_input(value: str, field_name: str) -> str: + parsed = parse_date_input(value, field_name) + return parsed.isoformat() if parsed else "" + + +def format_date_for_display(value: str) -> str: + if not value.strip(): + return "" + parsed = parse_iso_date(value, "Datum") + return parsed.strftime(system_date_pattern()) if parsed else "" + + +def parse_iso_date(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: + text = value.strip() + if not text: + if allow_empty: + return None + raise DateValidationError(f"{field_name} ist erforderlich.") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + raise DateValidationError(f"{field_name} muss das Format JJJJ-MM-TT haben.") + try: + return date.fromisoformat(text) + except ValueError as exc: + raise DateValidationError(f"{field_name} ist kein gültiges Kalenderdatum.") from exc + + +def validate_birth_date(value: str, *, today: date | None = None) -> date | None: + parsed = parse_iso_date(value, "Geburtsdatum") + if parsed is None: + return None + reference = today or date.today() + _validate_birth_date_value(parsed, reference) + return parsed + + +def validate_member_dates( + *, + birth_date: str, + accepted_at: str = "", + membership_started_at: str = "", + today: date | None = None, +) -> None: + reference = today or date.today() + birth = validate_birth_date(birth_date, today=reference) + accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference) + started = _validate_not_future(membership_started_at, "Mitglied seit", reference) + if birth and accepted and accepted < birth: + raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.") + if birth and started and started < birth: + raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.") + if accepted and started and started < accepted: + raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.") + + +def calculate_age(birth_date: date, on_date: date | None = None) -> int: + reference = on_date or date.today() + return ( + reference.year + - birth_date.year + - ((reference.month, reference.day) < (birth_date.month, birth_date.day)) + ) + + +def age_label(value: str, *, today: date | None = None) -> str: + if not value.strip(): + return "Alter: —" + try: + parsed = parse_date_input(value, "Geburtsdatum") + if parsed: + _validate_birth_date_value(parsed, today or date.today()) + except DateValidationError: + return "UNGÜLTIGES DATUM" + return f"Alter: {calculate_age(parsed, today)} Jahre" if parsed else "Alter: —" + + +def _validate_not_future(value: str, field_name: str, reference: date) -> date | None: + parsed = parse_iso_date(value, field_name) + if parsed and parsed > reference: + raise DateValidationError(f"{field_name} darf nicht in der Zukunft liegen.") + return parsed + + +def _validate_birth_date_value(parsed: date, reference: date) -> None: + if parsed > reference: + raise DateValidationError("Geburtsdatum darf nicht in der Zukunft liegen.") + if calculate_age(parsed, reference) > 120: + raise DateValidationError("Geburtsdatum ist unplausibel: Das berechnete Alter liegt über 120.") diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py new file mode 100644 index 0000000..bf1dd2f --- /dev/null +++ b/src/ccma/domain/models.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from typing import Any + + +def _iso_now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +MEMBERSHIP_STATUS_LABELS = { + "application": "ANTRAG", + "accepted_pending_payment": "ANGENOMMEN / ZAHLUNG OFFEN", + "active": "AKTIV", + "suspended_contribution": "RUHEND / BEITRAG", + "resigned_end_of_year": "AUSTRITT ZUM JAHRESENDE", + "honorary": "EHRENMITGLIED", + "ended": "BEENDET", +} + + +@dataclass(slots=True) +class Member: + member_id: str + member_number: str + first_name: str + last_name: str + email: str = "" + birth_date: str = "" + status: str = "application" + accepted_at: str = "" + membership_started_at: str = "" + payment_frequency: str = "annual" + contribution_rule_id: str = "standard-2022" + honorary: bool = False + notes: str = "" + created_at: str = field(default_factory=_iso_now) + updated_at: str = field(default_factory=_iso_now) + schema_version: int = 1 + + @property + def display_name(self) -> str: + return " ".join(part for part in (self.first_name, self.last_name) if part).strip() + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "member_id": self.member_id, + "member_number": self.member_number, + "person": { + "first_name": self.first_name, + "last_name": self.last_name, + "birth_date": self.birth_date, + "email": self.email, + }, + "membership": { + "status": self.status, + "accepted_at": self.accepted_at, + "started_at": self.membership_started_at, + "honorary": self.honorary, + }, + "contribution_profile": { + "rule_id": self.contribution_rule_id, + "payment_frequency": self.payment_frequency, + }, + "notes": self.notes, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Member: + person = data.get("person") or {} + membership = data.get("membership") or {} + contribution = data.get("contribution_profile") or {} + return cls( + schema_version=int(data.get("schema_version", 1)), + member_id=str(data["member_id"]), + member_number=str(data.get("member_number", "")), + first_name=str(person.get("first_name", "")), + last_name=str(person.get("last_name", "")), + email=str(person.get("email", "")), + birth_date=str(person.get("birth_date", "")), + status=str(membership.get("status", "application")), + accepted_at=str(membership.get("accepted_at", "")), + membership_started_at=str(membership.get("started_at", "")), + honorary=bool(membership.get("honorary", False)), + contribution_rule_id=str(contribution.get("rule_id", "standard-2022")), + payment_frequency=str(contribution.get("payment_frequency", "annual")), + notes=str(data.get("notes", "")), + created_at=str(data.get("created_at", _iso_now())), + updated_at=str(data.get("updated_at", _iso_now())), + ) + + +@dataclass(slots=True) +class Event: + event_id: str + timestamp: str + event_type: str + summary: str + actor_type: str = "system" + actor_name: str = "CCMA" + references: dict[str, str] = field(default_factory=dict) + data: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": 1, + "event_id": self.event_id, + "timestamp": self.timestamp, + "type": self.event_type, + "actor": {"type": self.actor_type, "name": self.actor_name}, + "summary": self.summary, + "references": self.references, + "data": self.data, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Event: + actor = data.get("actor") or {} + return cls( + event_id=str(data["event_id"]), + timestamp=str(data["timestamp"]), + event_type=str(data.get("type", "unknown")), + summary=str(data.get("summary", "")), + actor_type=str(actor.get("type", "system")), + actor_name=str(actor.get("name", "CCMA")), + references=dict(data.get("references") or {}), + data=dict(data.get("data") or {}), + ) + + +@dataclass(slots=True) +class ContributionData: + claims: list[dict[str, Any]] = field(default_factory=list) + payments: list[dict[str, Any]] = field(default_factory=list) + schema_version: int = 1 + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "claims": self.claims, + "payments": self.payments, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ContributionData: + return cls( + schema_version=int(data.get("schema_version", 1)), + claims=list(data.get("claims") or []), + payments=list(data.get("payments") or []), + ) + + +@dataclass(frozen=True, slots=True) +class HousekeeperFinding: + severity: str + member_id: str + code: str + title: str + detail: str + due_date: date | None = None + + +def money(value: str | int | float | Decimal) -> Decimal: + return Decimal(str(value)).quantize(Decimal("0.01")) diff --git a/src/ccma/services/__init__.py b/src/ccma/services/__init__.py new file mode 100644 index 0000000..6803af4 --- /dev/null +++ b/src/ccma/services/__init__.py @@ -0,0 +1,3 @@ +from ccma.services.housekeeper import Housekeeper + +__all__ = ["Housekeeper"] diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py new file mode 100644 index 0000000..105719f --- /dev/null +++ b/src/ccma/services/housekeeper.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import calendar +from dataclasses import dataclass, field +from datetime import date, timedelta + +from ccma.domain.dates import DateValidationError, parse_iso_date, validate_birth_date +from ccma.domain.models import HousekeeperFinding +from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals +from ccma.storage.repository import MemberRepository + + +@dataclass(frozen=True, slots=True) +class HousekeeperSettings: + birthday_days_before: int = 7 + birthday_days_after: int = 2 + anniversary_days_before: int = 14 + anniversary_days_after: int = 7 + anniversary_intervals: tuple[AnniversaryInterval, ...] = field( + default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y")) + ) + + @classmethod + def from_values( + cls, + *, + birthday_days_before: int, + birthday_days_after: int, + anniversary_days_before: int, + anniversary_days_after: int, + anniversary_intervals: str, + ) -> HousekeeperSettings: + return cls( + birthday_days_before=min(365, max(0, birthday_days_before)), + birthday_days_after=min(365, max(0, birthday_days_after)), + anniversary_days_before=min(365, max(0, anniversary_days_before)), + anniversary_days_after=min(365, max(0, anniversary_days_after)), + anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)), + ) + + +class Housekeeper: + def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None): + self.repository = repository + self.settings = settings or HousekeeperSettings() + + def run(self, today: date | None = None) -> list[HousekeeperFinding]: + current_date = today or date.today() + findings: list[HousekeeperFinding] = [] + for member in self.repository.list_members(): + if member.status in { + "active", + "suspended_contribution", + "resigned_end_of_year", + "honorary", + }: + birthday = self._birthday_finding( + member.member_id, member.display_name, member.birth_date, current_date + ) + if birthday: + findings.append(birthday) + findings.extend( + self._anniversary_findings( + member.member_id, + member.display_name, + member.membership_started_at, + current_date, + ) + ) + if not member.contribution_rule_id and not member.honorary: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="missing_contribution_rule", + title=f"{member.display_name}: Beitragsregel fehlt", + detail="Dem Mitglied ist keine Beitragsregel zugeordnet.", + ) + ) + + if member.status == "accepted_pending_payment" and member.accepted_at: + accepted = _parse_date(member.accepted_at) + if accepted: + deadline = accepted + timedelta(days=28) + days = (deadline - current_date).days + if days < 0: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="initial_payment_overdue", + title=f"{member.display_name}: Erstzahlung überfällig", + detail=f"Die Vierwochenfrist ist seit {-days} Tagen überschritten.", + due_date=deadline, + ) + ) + elif days <= 7: + findings.append( + HousekeeperFinding( + severity="warning", + member_id=member.member_id, + code="initial_payment_due_soon", + title=f"{member.display_name}: Erstzahlung bald fällig", + detail=f"Die Vierwochenfrist endet in {days} Tagen.", + due_date=deadline, + ) + ) + + contributions = self.repository.get_contributions(member.member_id) + for claim in contributions.claims: + if str(claim.get("status", "open")) not in {"open", "partially_paid"}: + continue + due = _parse_date(str(claim.get("due_date", ""))) + if not due: + continue + days = (due - current_date).days + title = str(claim.get("title") or "Beitragsforderung") + if days < 0: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="claim_overdue", + title=f"{member.display_name}: {title} überfällig", + detail=f"Fälligkeit war vor {-days} Tagen.", + due_date=due, + ) + ) + elif days <= 14: + findings.append( + HousekeeperFinding( + severity="info", + member_id=member.member_id, + code="claim_due_soon", + title=f"{member.display_name}: {title} bald fällig", + detail=f"Fälligkeit in {days} Tagen.", + due_date=due, + ) + ) + severity_order = {"error": 0, "warning": 1, "info": 2} + return sorted( + findings, key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max) + ) + + def _birthday_finding( + self, + member_id: str, + name: str, + birth_date_value: str, + today: date, + ) -> HousekeeperFinding | None: + try: + birth_date = validate_birth_date(birth_date_value, today=today) + except DateValidationError: + return None + if not birth_date: + return None + occurrences = [_birthday_in_year(birth_date, year) for year in range(today.year - 1, today.year + 2)] + occurrence = min(occurrences, key=lambda value: abs((value - today).days)) + delta = (occurrence - today).days + if delta > self.settings.birthday_days_before or delta < -self.settings.birthday_days_after: + return None + age = occurrence.year - birth_date.year + title = _relative_title(name, delta, "Geburtstag") + detail = f"Wird {age} Jahre alt." if delta >= 0 else f"Ist {age} Jahre alt geworden." + return HousekeeperFinding( + severity="info", + member_id=member_id, + code="birthday", + title=title, + detail=detail, + due_date=occurrence, + ) + + def _anniversary_findings( + self, + member_id: str, + name: str, + started_at_value: str, + today: date, + ) -> list[HousekeeperFinding]: + try: + started_at = parse_iso_date(started_at_value, "Mitglied seit") + except DateValidationError: + return [] + if not started_at: + return [] + findings: list[HousekeeperFinding] = [] + for interval in self.settings.anniversary_intervals: + try: + target = interval.target_date(started_at) + except (OverflowError, ValueError): + continue + delta = (target - today).days + if delta > self.settings.anniversary_days_before or delta < -self.settings.anniversary_days_after: + continue + occasion = _anniversary_name(interval) + findings.append( + HousekeeperFinding( + severity="info", + member_id=member_id, + code="membership_anniversary", + title=_relative_title(name, delta, occasion), + detail=f"Mitglied seit {started_at:%d.%m.%Y}.", + due_date=target, + ) + ) + return findings + + +def _parse_date(value: str) -> date | None: + try: + return date.fromisoformat(value[:10]) + except (TypeError, ValueError): + return None + + +def _birthday_in_year(birth_date: date, year: int) -> date: + day = min(birth_date.day, calendar.monthrange(year, birth_date.month)[1]) + return date(year, birth_date.month, day) + + +def _relative_title(name: str, delta: int, occasion: str) -> str: + if delta == 0: + return f"{name} hat heute {occasion}" + days = "Tag" if abs(delta) == 1 else "Tagen" + if delta > 0: + return f"{name} hat in {delta} {days} {occasion}" + return f"{name} hatte vor {-delta} {days} {occasion}" + + +def _anniversary_name(interval: AnniversaryInterval) -> str: + if interval.unit == "D": + return f"{interval.value}-Tage-Mitgliedsjubiläum" + if interval.unit == "M": + return f"{interval.value}-Monats-Mitgliedsjubiläum" + return f"{interval.value}-jähriges Mitgliedsjubiläum" diff --git a/src/ccma/services/intervals.py b/src/ccma/services/intervals.py new file mode 100644 index 0000000..a541866 --- /dev/null +++ b/src/ccma/services/intervals.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import calendar +import re +from dataclasses import dataclass +from datetime import date, timedelta + + +class IntervalValidationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class AnniversaryInterval: + value: int + unit: str + + @property + def token(self) -> str: + return f"{self.value}{self.unit}" + + @property + def label(self) -> str: + labels = { + "D": "Tag" if self.value == 1 else "Tage", + "M": "Monat" if self.value == 1 else "Monate", + "Y": "Jahr" if self.value == 1 else "Jahre", + } + return f"{self.value} {labels[self.unit]}" + + def target_date(self, start: date) -> date: + if self.unit == "D": + return start + timedelta(days=self.value) + if self.unit == "M": + return _add_months(start, self.value) + return _add_years(start, self.value) + + +def parse_anniversary_intervals(value: str) -> list[AnniversaryInterval]: + tokens = [token.strip().upper() for token in re.split(r"[;,]", value) if token.strip()] + if not tokens: + raise IntervalValidationError("Mindestens ein Jubiläumsintervall ist erforderlich.") + intervals: list[AnniversaryInterval] = [] + seen: set[str] = set() + maximums = {"D": 36_600, "M": 1_200, "Y": 100} + for token in tokens: + match = re.fullmatch(r"(\d+)([DMY]?)", token) + if not match: + raise IntervalValidationError( + f"Ungültiges Intervall {token!r}. Erwartet werden Angaben wie 1, 30D, 2M oder 10Y." + ) + amount, unit = int(match.group(1)), match.group(2) or "Y" + if amount < 1 or amount > maximums[unit]: + raise IntervalValidationError(f"Intervall {token!r} liegt außerhalb des erlaubten Bereichs.") + normalized = f"{amount}{unit}" + if normalized not in seen: + intervals.append(AnniversaryInterval(amount, unit)) + seen.add(normalized) + return intervals + + +def normalize_anniversary_intervals(value: str) -> str: + return ";".join(interval.token for interval in parse_anniversary_intervals(value)) + + +def _add_months(value: date, months: int) -> date: + month_index = value.month - 1 + months + year = value.year + month_index // 12 + month = month_index % 12 + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) + + +def _add_years(value: date, years: int) -> date: + target_year = value.year + years + day = min(value.day, calendar.monthrange(target_year, value.month)[1]) + return date(target_year, value.month, day) diff --git a/src/ccma/storage/__init__.py b/src/ccma/storage/__init__.py new file mode 100644 index 0000000..55f69be --- /dev/null +++ b/src/ccma/storage/__init__.py @@ -0,0 +1,3 @@ +from ccma.storage.repository import MemberRepository, RepositoryError + +__all__ = ["MemberRepository", "RepositoryError"] diff --git a/src/ccma/storage/atomic.py b/src/ccma/storage/atomic.py new file mode 100644 index 0000000..2cfd925 --- /dev/null +++ b/src/ccma/storage/atomic.py @@ -0,0 +1,25 @@ +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def write_json_atomic(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def read_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py new file mode 100644 index 0000000..fb3b977 --- /dev/null +++ b/src/ccma/storage/repository.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import json +import os +import unicodedata +from collections.abc import Iterable +from datetime import date, datetime +from pathlib import Path +from string import Formatter +from uuid import uuid4 + +from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member +from ccma.storage.atomic import read_json, write_json_atomic + + +class RepositoryError(RuntimeError): + pass + + +DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}" + + +DEFAULT_CONFIGURATION = { + "schema_version": 1, + "organization": "Chaos Computer Club Mannheim e.V.", + "member_number_policy": { + "mode": "automatic", + "pattern": DEFAULT_MEMBER_NUMBER_PATTERN, + }, + "member_number_sequences": {}, + "contribution_rules": [ + { + "rule_id": "standard-2022", + "name": "Regulärer Beitrag ab 2022", + "valid_from": "2022-01-01", + "annual_amount": "150.00", + "admission_fee": "10.00", + "annual_due": "01-31", + "semiannual_due": ["01-31", "07-31"], + "first_payment_due_days_after_acceptance": 28, + "reminder_fee": "5.00", + "failed_debit_fee": "5.00", + } + ], +} + + +class MemberRepository: + def __init__(self, root: Path | str): + self.root = Path(root).expanduser().resolve() + self.members_root = self.root / "members" + + def initialize(self) -> None: + self.members_root.mkdir(parents=True, exist_ok=True) + config_path = self.root / "repository.json" + if not config_path.exists(): + write_json_atomic(config_path, DEFAULT_CONFIGURATION) + + def validate(self) -> list[str]: + errors: list[str] = [] + try: + config = read_json(self.root / "repository.json") + if int(config.get("schema_version", 0)) != 1: + errors.append("repository.json: nicht unterstützte schema_version") + policy = config.get("member_number_policy") or {} + if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}: + errors.append("repository.json: ungültiger Mitgliedsnummernmodus") + validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))) + except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc: + errors.append(f"repository.json: {exc}") + + seen_numbers: dict[str, str] = {} + for member_dir in self._member_directories(): + try: + member = self.get_member(member_dir.name) + validate_member_dates( + birth_date=member.birth_date, + accepted_at=member.accepted_at, + membership_started_at=member.membership_started_at, + ) + if member.member_id != member_dir.name: + errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein") + normalized_number = member.member_number.casefold().strip() + if normalized_number and normalized_number in seen_numbers: + errors.append( + f"{member_dir.name}/member.json: Mitgliedsnummer {member.member_number} ist doppelt" + ) + elif normalized_number: + seen_numbers[normalized_number] = member.member_id + except ( + OSError, + ValueError, + TypeError, + KeyError, + json.JSONDecodeError, + DateValidationError, + ) as exc: + errors.append(f"{member_dir.name}/member.json: {exc}") + return errors + + def list_members(self) -> list[Member]: + members: list[Member] = [] + for directory in self._member_directories(): + try: + members.append(self.get_member(directory.name)) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): + continue + return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold())) + + def get_member(self, member_id: str) -> Member: + path = self._member_path(member_id) / "member.json" + if not path.is_file(): + raise RepositoryError(f"Mitglied nicht gefunden: {member_id}") + return Member.from_dict(read_json(path)) + + def create_member( + self, + *, + first_name: str, + last_name: str, + email: str = "", + birth_date: str = "", + member_number: str = "", + ) -> Member: + if not first_name.strip() or not last_name.strip(): + raise RepositoryError("Vorname und Nachname sind erforderlich.") + try: + birth_date = normalize_date_input(birth_date, "Geburtsdatum") + validate_member_dates(birth_date=birth_date) + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + selected_number = member_number.strip() + policy = self.get_member_number_policy() + if selected_number: + self._assert_member_number_available(selected_number) + elif policy["mode"] == "manual": + raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.") + else: + selected_number = self._allocate_member_number(policy["pattern"]) + member_id = str(uuid4()) + directory = self._member_path(member_id) + directory.mkdir(parents=True, exist_ok=False) + (directory / "files").mkdir() + member = Member( + member_id=member_id, + member_number=selected_number, + first_name=first_name.strip(), + last_name=last_name.strip(), + email=email.strip(), + birth_date=birth_date, + ) + write_json_atomic(directory / "member.json", member.to_dict()) + write_json_atomic(directory / "contributions.json", ContributionData().to_dict()) + self.append_event( + member_id, + event_type="member_created", + summary="Mitgliederakte angelegt", + actor_type="user", + actor_name="Vorstand", + ) + return member + + def save_member(self, member: Member, *, actor_name: str = "Vorstand") -> None: + existing = self.get_member(member.member_id) + try: + member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum") + member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") + member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit") + validate_member_dates( + birth_date=member.birth_date, + accepted_at=member.accepted_at, + membership_started_at=member.membership_started_at, + ) + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + if member.member_number != existing.member_number: + self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id) + changes = self._summarize_changes(existing, member) + member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + write_json_atomic(self._member_path(member.member_id) / "member.json", member.to_dict()) + if changes: + self.append_event( + member.member_id, + event_type="member_data_changed", + summary=f"Mitgliedsdaten geändert: {', '.join(changes)}", + actor_type="user", + actor_name=actor_name, + ) + + def get_contributions(self, member_id: str) -> ContributionData: + path = self._member_path(member_id) / "contributions.json" + if not path.exists(): + return ContributionData() + return ContributionData.from_dict(read_json(path)) + + def save_contributions(self, member_id: str, data: ContributionData) -> None: + self.get_member(member_id) + write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict()) + + def append_event( + self, + member_id: str, + *, + event_type: str, + summary: str, + actor_type: str = "system", + actor_name: str = "CCMA", + references: dict[str, str] | None = None, + data: dict[str, object] | None = None, + ) -> Event: + directory = self._member_path(member_id) + if not (directory / "member.json").is_file(): + raise RepositoryError(f"Mitglied nicht gefunden: {member_id}") + event = Event( + event_id=str(uuid4()), + timestamp=datetime.now().astimezone().isoformat(timespec="seconds"), + event_type=event_type, + summary=summary.strip(), + actor_type=actor_type, + actor_name=actor_name, + references=references or {}, + data=data or {}, + ) + path = directory / "events.jsonl" + line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n" + with path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(line) + handle.flush() + os.fsync(handle.fileno()) + return event + + def get_events(self, member_id: str) -> list[Event]: + path = self._member_path(member_id) / "events.jsonl" + if not path.exists(): + return [] + events: list[Event] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + events.append(Event.from_dict(json.loads(line))) + except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc + return events + + def search(self, query: str) -> list[Member]: + tokens = [_normalize(token) for token in query.split() if token.strip()] + if not tokens: + return self.list_members() + scored: list[tuple[int, Member]] = [] + for member in self.list_members(): + fields = [ + member.member_number, + member.first_name, + member.last_name, + member.display_name, + member.email, + member.birth_date, + _german_date(member.birth_date), + ] + normalized = [_normalize(value) for value in fields if value] + if not all(any(token in value for value in normalized) for token in tokens): + continue + exact = sum(token == value for token in tokens for value in normalized) + prefix = sum(value.startswith(token) for token in tokens for value in normalized) + scored.append((exact * 100 + prefix * 10, member)) + scored.sort(key=lambda item: (-item[0], item[1].last_name.casefold(), item[1].first_name.casefold())) + return [member for _, member in scored] + + def member_count(self) -> int: + return sum(1 for _ in self._member_directories()) + + def get_member_number_policy(self) -> dict[str, str]: + try: + config = read_json(self.root / "repository.json") + except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + raise RepositoryError(f"Mitgliedsnummernregel konnte nicht gelesen werden: {exc}") from exc + policy = config.get("member_number_policy") or {} + mode = str(policy.get("mode", "automatic")) + if mode not in {"automatic", "manual"}: + mode = "automatic" + pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)) + validate_member_number_pattern(pattern) + return {"mode": mode, "pattern": pattern} + + def save_member_number_policy(self, *, mode: str, pattern: str) -> None: + if mode not in {"automatic", "manual"}: + raise RepositoryError("Ungültiger Mitgliedsnummernmodus.") + validate_member_number_pattern(pattern) + config = read_json(self.root / "repository.json") + config["member_number_policy"] = {"mode": mode, "pattern": pattern.strip()} + config.setdefault("member_number_sequences", {}) + write_json_atomic(self.root / "repository.json", config) + + def preview_member_number(self, pattern: str | None = None) -> str: + selected_pattern = pattern or self.get_member_number_policy()["pattern"] + validate_member_number_pattern(selected_pattern) + config = read_json(self.root / "repository.json") + return self._next_available_member_number(config, selected_pattern)[0] + + def _member_directories(self) -> Iterable[Path]: + if not self.members_root.exists(): + return [] + return ( + path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".") + ) + + def _member_path(self, member_id: str) -> Path: + if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}: + raise RepositoryError("Ungültige Mitglieds-ID.") + return self.members_root / member_id + + def _allocate_member_number(self, pattern: str) -> str: + config = read_json(self.root / "repository.json") + member_number, next_value = self._next_available_member_number(config, pattern) + sequences = config.get("member_number_sequences") + if not isinstance(sequences, dict): + sequences = {} + config["member_number_sequences"] = sequences + sequences[pattern] = next_value + write_json_atomic(self.root / "repository.json", config) + return member_number + + def _next_available_member_number(self, config: dict, pattern: str) -> tuple[str, int]: + sequences = config.get("member_number_sequences") + if not isinstance(sequences, dict): + sequences = {} + try: + number = max(1, int(sequences.get(pattern, 1))) + except (TypeError, ValueError): + number = 1 + existing = {member.member_number.casefold() for member in self.list_members() if member.member_number} + for _attempt in range(1_000_000): + candidate = format_member_number(pattern, number) + number += 1 + if candidate.casefold() not in existing: + return candidate, number + raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.") + + def _assert_member_number_available( + self, + member_number: str, + *, + exclude_member_id: str | None = None, + ) -> None: + normalized = member_number.casefold().strip() + if not normalized: + raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.") + for member in self.list_members(): + if member.member_id != exclude_member_id and member.member_number.casefold() == normalized: + raise RepositoryError(f"Die Mitgliedsnummer {member_number} ist bereits vergeben.") + + @staticmethod + def _summarize_changes(before: Member, after: Member) -> list[str]: + labels = { + "member_number": "Mitgliedsnummer", + "first_name": "Vorname", + "last_name": "Nachname", + "email": "E-Mail-Adresse", + "birth_date": "Geburtsdatum", + "status": "Status", + "payment_frequency": "Zahlungsweise", + "contribution_rule_id": "Beitragsregel", + "honorary": "Ehrenmitgliedschaft", + "notes": "interne Notiz", + } + changes: list[str] = [] + for field, label in labels.items(): + old_value = getattr(before, field) + new_value = getattr(after, field) + if old_value == new_value: + continue + if field == "status": + old_label = MEMBERSHIP_STATUS_LABELS.get(str(old_value), str(old_value)) + new_label = MEMBERSHIP_STATUS_LABELS.get(str(new_value), str(new_value)) + changes.append(f"Status von {old_label} zu {new_label}") + else: + changes.append(label) + return changes + + +def _normalize(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value.casefold().strip()) + return "".join(character for character in normalized if not unicodedata.combining(character)) + + +def _german_date(value: str) -> str: + try: + return date.fromisoformat(value).strftime("%d.%m.%Y") + except ValueError: + return "" + + +def validate_member_number_pattern(pattern: str) -> None: + selected = pattern.strip() + if not selected: + raise RepositoryError("Das Mitgliedsnummern-Pattern darf nicht leer sein.") + has_number = False + try: + for _literal, field_name, format_spec, conversion in Formatter().parse(selected): + if field_name is None: + continue + if field_name not in {"number", "year"}: + raise RepositoryError(f"Unbekannter Platzhalter: {{{field_name}}}") + if conversion: + raise RepositoryError("Konvertierungen wie !r sind im Pattern nicht erlaubt.") + if "{" in format_spec or "}" in format_spec: + raise RepositoryError("Verschachtelte Formatierungen sind nicht erlaubt.") + has_number = has_number or field_name == "number" + if not has_number: + raise RepositoryError("Das Pattern muss den Platzhalter {number} enthalten.") + first = format_member_number(selected, 1) + second = format_member_number(selected, 2) + if first == second: + raise RepositoryError("Das Pattern erzeugt keine eindeutigen Mitgliedsnummern.") + except RepositoryError: + raise + except (KeyError, ValueError) as exc: + raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc + + +def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str: + try: + value = pattern.strip().format(number=number, year=year or date.today().year) + except (KeyError, ValueError) as exc: + raise RepositoryError(f"Mitgliedsnummer konnte nicht formatiert werden: {exc}") from exc + if not value or len(value) > 80 or any(character in value for character in "\r\n\t"): + raise RepositoryError("Das Pattern erzeugt eine ungültige Mitgliedsnummer.") + return value diff --git a/src/ccma/ui/__init__.py b/src/ccma/ui/__init__.py new file mode 100644 index 0000000..09e76d6 --- /dev/null +++ b/src/ccma/ui/__init__.py @@ -0,0 +1 @@ +"""Tk user interface for CCMA.""" diff --git a/src/ccma/ui/changelog_view.py b/src/ccma/ui/changelog_view.py new file mode 100644 index 0000000..07bb0d4 --- /dev/null +++ b/src/ccma/ui/changelog_view.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import tkinter as tk +from pathlib import Path +from tkinter import ttk +from typing import Any + +from ccma import __version__ +from ccma.ui.icons import IconStore + +CHANGELOG_PATH = Path(__file__).resolve().parent.parent / "assets" / "CHANGELOG.json" + + +def load_changelog(path: Path = CHANGELOG_PATH) -> list[dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return [] + if not isinstance(data, list): + return [] + return [entry for entry in data if isinstance(entry, dict)] + + +class ChangelogView(ttk.Frame): + def __init__(self, master: tk.Misc): + super().__init__(master, padding=14) + self.icons = IconStore(self) + self.entries = load_changelog() + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 14)) + header.columnconfigure(1, weight=1) + history_icon = self.icons.get("history", 28) + icon_label = ttk.Label(header, image=history_icon) + icon_label.image = history_icon + icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10)) + ttk.Label(header, text="Änderungsverlauf", style="TabTitle.TLabel").grid(row=0, column=1, sticky="w") + ttk.Label( + header, + text=f"Aktuelle Version {__version__} · {len(self.entries)} Release-Einträge", + style="Mono.TLabel", + ).grid(row=1, column=1, sticky="w", pady=(2, 0)) + + container = ttk.Frame(self) + container.grid(row=1, column=0, sticky="nsew") + container.columnconfigure(0, weight=1) + container.rowconfigure(0, weight=1) + background = ttk.Style(self).lookup("TFrame", "background") or "#ffffff" + self.canvas = tk.Canvas(container, highlightthickness=0, borderwidth=0, background=background) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview) + self.canvas.configure(yscrollcommand=scrollbar.set) + self.canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + self.content = ttk.Frame(self.canvas) + self.content.columnconfigure(0, weight=1) + self.content_window = self.canvas.create_window((0, 0), anchor="nw", window=self.content) + self.content.bind("", self._update_scroll_region) + self.canvas.bind("", self._resize_content) + self._render_entries() + self._bind_mousewheel_tree(self.canvas) + self._bind_mousewheel_tree(self.content) + + def _render_entries(self) -> None: + if not self.entries: + ttk.Label(self.content, text="Kein Changelog verfügbar.", style="Muted.TLabel").grid( + row=0, column=0, sticky="w", padx=8, pady=8 + ) + return + for row, entry in enumerate(self.entries): + version = str(entry.get("version", "unbekannt")) + card = ttk.LabelFrame(self.content, padding=14) + card.grid(row=row, column=0, sticky="ew", padx=(0, 8), pady=(0, 12)) + card.columnconfigure(1, weight=1) + ttk.Label(card, text=f"VERSION {version}", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w" + ) + if version == __version__: + ttk.Label(card, text="CURRENT", style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(12, 0) + ) + ttk.Label(card, text=str(entry.get("date", "")), style="Muted.TLabel").grid( + row=0, column=2, sticky="e" + ) + changes = entry.get("changes", []) + if not isinstance(changes, list): + continue + for change_row, change in enumerate(changes, start=1): + ttk.Label(card, text="•", style="TimelineHeader.TLabel").grid( + row=change_row, column=0, sticky="nw", pady=(7, 0) + ) + ttk.Label( + card, + text=str(change), + justify="left", + wraplength=650, + ).grid(row=change_row, column=1, columnspan=2, sticky="w", padx=(8, 0), pady=(7, 0)) + + def _update_scroll_region(self, _event: tk.Event | None = None) -> None: + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def _resize_content(self, event: tk.Event) -> None: + self.canvas.itemconfigure(self.content_window, width=event.width) + + def _bind_mousewheel_tree(self, widget: tk.Misc) -> None: + widget.bind("", self._on_mousewheel, add="+") + widget.bind("", self._on_mousewheel, add="+") + widget.bind("", self._on_mousewheel, add="+") + for child in widget.winfo_children(): + self._bind_mousewheel_tree(child) + + def _on_mousewheel(self, event: tk.Event) -> None: + if getattr(event, "num", None) == 4: + delta = -1 + elif getattr(event, "num", None) == 5: + delta = 1 + else: + delta = -1 if getattr(event, "delta", 0) > 0 else 1 + self.canvas.yview_scroll(delta * 3, "units") diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py new file mode 100644 index 0000000..a8c997b --- /dev/null +++ b/src/ccma/ui/dialogs.py @@ -0,0 +1,90 @@ +import tkinter as tk +from collections.abc import Callable +from tkinter import messagebox, ttk + +from ccma.domain.dates import age_label, date_input_hint +from ccma.domain.models import Member +from ccma.storage.repository import MemberRepository, RepositoryError + + +class NewMemberDialog(tk.Toplevel): + def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]): + super().__init__(master) + self.repository = repository + self.on_created = on_created + self.title("Neue Mitgliederakte") + self.transient(master.winfo_toplevel()) + self.grab_set() + self.resizable(False, False) + self.number_policy = repository.get_member_number_policy() + self.variables = { + name: tk.StringVar() + for name in ("first_name", "last_name", "email", "birth_date", "member_number") + } + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._create()) + self.after_idle(self._focus_first) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + fields = [ + ("Vorname *", "first_name"), + ("Nachname *", "last_name"), + ("E-Mail-Adresse", "email"), + (f"Geburtsdatum ({date_input_hint()})", "birth_date"), + ] + if self.number_policy["mode"] == "manual": + fields.append(("Mitgliedsnummer *", "member_number")) + self.entries: dict[str, ttk.Entry] = {} + for row, (label, key) in enumerate(fields): + ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + if key == "birth_date": + birth_row = ttk.Frame(frame) + birth_row.grid(row=row, column=1, sticky="ew", pady=5) + birth_row.columnconfigure(0, weight=1) + entry = ttk.Entry(birth_row, textvariable=self.variables[key], width=24) + entry.grid(row=0, column=0, sticky="ew") + self.birth_age_var = tk.StringVar(value="Alter: —") + ttk.Label(birth_row, textvariable=self.birth_age_var, style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(10, 0) + ) + self.variables[key].trace_add( + "write", + lambda *_args: self.birth_age_var.set(age_label(self.variables["birth_date"].get())), + ) + else: + entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) + entry.grid(row=row, column=1, sticky="ew", pady=5) + self.entries[key] = entry + button_row = len(fields) + if self.number_policy["mode"] == "automatic": + preview = self.repository.preview_member_number(self.number_policy["pattern"]) + ttk.Label(frame, text="Mitgliedsnummer").grid( + row=button_row, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Label(frame, text=f"Automatisch: {preview}", style="TimelineHeader.TLabel").grid( + row=button_row, column=1, sticky="w", pady=5 + ) + button_row += 1 + buttons = ttk.Frame(frame) + buttons.grid(row=button_row, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Akte anlegen", style="Accent.TButton", command=self._create).pack( + side="left" + ) + + def _focus_first(self) -> None: + self.entries["first_name"].focus_set() + + def _create(self) -> None: + try: + member = self.repository.create_member( + **{key: variable.get() for key, variable in self.variables.items()} + ) + except RepositoryError as exc: + messagebox.showerror("Akte konnte nicht angelegt werden", str(exc), parent=self) + return + self.destroy() + self.on_created(member) diff --git a/src/ccma/ui/icons.py b/src/ccma/ui/icons.py new file mode 100644 index 0000000..bbe9107 --- /dev/null +++ b/src/ccma/ui/icons.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import tkinter as tk +from importlib import import_module, resources +from pathlib import Path +from tkinter import ttk + + +class IconStore: + """Load and cache Material Design icons used by ttk widgets.""" + + def __init__(self, master: tk.Misc): + self.master = master + self.style = ttk.Style(master) + self._cache: dict[tuple[str, int, str], tk.PhotoImage | None] = {} + self._mat_icon = self._load_mat_icon_class() + + def get(self, name: str, size: int = 16, color: str | None = None) -> tk.PhotoImage | None: + resolved_color = color or self._theme_color() + key = (name, size, resolved_color) + if key not in self._cache: + self._cache[key] = self._load(name, size, resolved_color) + return self._cache[key] + + def clear(self) -> None: + self._cache.clear() + + def _load(self, name: str, size: int, color: str) -> tk.PhotoImage | None: + if self._mat_icon: + try: + return self._mat_icon(name, size=size, color=color).image + except Exception: + pass + return self._load_asset_fallback(name, size) + + def _load_asset_fallback(self, name: str, size: int) -> tk.PhotoImage | None: + for module_name in ("ttkbootstrap_icons_mat", "ttkbootstrap_icons"): + try: + root = resources.files(import_module(module_name)) + except (ImportError, TypeError): + continue + candidates = {name, name.replace("-", "_"), name.replace("_", "-")} + for candidate in candidates: + for extension in (".png", ".gif"): + try: + path = Path(next(root.rglob(f"{candidate}{extension}"))) + image = tk.PhotoImage(master=self.master, file=str(path)) + if size and image.width() > size: + factor = max(1, round(image.width() / size)) + image = image.subsample(factor, factor) + return image + except (StopIteration, tk.TclError, OSError): + continue + return None + + @staticmethod + def _load_mat_icon_class(): + try: + module = import_module("ttkbootstrap_icons_mat") + except ImportError: + return None + return getattr(module, "MatIcon", None) + + def _theme_color(self) -> str: + try: + theme_name = self.style.theme_use().lower() + except tk.TclError: + theme_name = "" + if "dark" in theme_name: + return "#eeeeee" + if "light" in theme_name: + return "#313131" + background = self.style.lookup("TFrame", "background") or "#ffffff" + try: + red, green, blue = self.master.winfo_rgb(background) + luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 65535 + return "#eeeeee" if luminance < 0.5 else "#313131" + except tk.TclError: + return "#313131" diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py new file mode 100644 index 0000000..e93ce18 --- /dev/null +++ b/src/ccma/ui/main_window.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk + +from ccma import __version__ +from ccma.config import AppConfig +from ccma.domain.models import HousekeeperFinding, Member +from ccma.services.housekeeper import Housekeeper +from ccma.storage.repository import MemberRepository +from ccma.ui.dialogs import NewMemberDialog +from ccma.ui.icons import IconStore +from ccma.ui.member_tab import MemberTab +from ccma.ui.options_dialog import OptionsDialog +from ccma.ui.theme import load_theme +from ccma.ui.work_tabs import DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab + + +class TabManager: + def __init__(self, notebook: ttk.Notebook): + self.notebook = notebook + self.tabs: dict[str, tk.Widget] = {} + self.images: dict[str, tk.PhotoImage] = {} + self.icon_names: dict[str, str] = {} + + def add( + self, + key: str, + widget: tk.Widget, + title: str, + *, + select: bool = True, + image: tk.PhotoImage | None = None, + icon_name: str | None = None, + ) -> None: + self.tabs[key] = widget + options: dict[str, object] = {"text": title, "padding": (6, 3)} + if image: + options.update(image=image, compound="left") + self.images[key] = image + if icon_name: + self.icon_names[key] = icon_name + self.notebook.add(widget, **options) + if select: + self.notebook.select(widget) + + def focus(self, key: str) -> bool: + widget = self.tabs.get(key) + if widget and widget.winfo_exists(): + self.notebook.select(widget) + return True + self.tabs.pop(key, None) + return False + + def close(self, key: str) -> None: + widget = self.tabs.pop(key, None) + self.images.pop(key, None) + self.icon_names.pop(key, None) + if widget and widget.winfo_exists(): + self.notebook.forget(widget) + widget.destroy() + + def refresh_icons(self, icons: IconStore) -> None: + for key, icon_name in self.icon_names.items(): + widget = self.tabs.get(key) + image = icons.get(icon_name, 16) + if widget and widget.winfo_exists() and image: + self.images[key] = image + self.notebook.tab(widget, image=image, compound="left") + + +class MainWindow(ttk.Frame): + def __init__( + self, + master: tk.Tk, + repository: MemberRepository, + config: AppConfig, + findings: list[HousekeeperFinding], + validation_errors: list[str], + ): + super().__init__(master, padding=(12, 8)) + self.repository = repository + self.config = config + self.findings = findings + self.validation_errors = validation_errors + self.search_var = tk.StringVar() + self.status_var = tk.StringVar(value="Bereit.") + self.search_counter = 1 + self.icons = IconStore(self) + self.ribbon: ttk.Frame | None = None + self.housekeeper_button: ttk.Button | None = None + self._build_ui() + if validation_errors: + self.after_idle(self._show_validation_warning) + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + self._build_ribbon() + self.notebook = ttk.Notebook(self) + self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0)) + self.tabs = TabManager(self.notebook) + self.dashboard = DashboardTab( + self.notebook, + self.repository.member_count(), + self.findings, + self.open_housekeeper, + ) + self.tabs.add( + "dashboard", + self.dashboard, + "Dashboard", + image=self.icons.get("view-dashboard", 16), + icon_name="view-dashboard", + ) + status = ttk.Frame(self, padding=(6, 5)) + status.grid(row=2, column=0, sticky="ew") + status.columnconfigure(0, weight=1) + ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label( + status, + text=f"STORE {self.repository.root} · VERSION {__version__}", + style="Status.TLabel", + ).grid(row=0, column=1, sticky="e") + + def _build_ribbon(self) -> None: + ribbon = ttk.Frame(self, style="Ribbon.TFrame") + self.ribbon = ribbon + ribbon.grid(row=0, column=0, sticky="ew") + ribbon.columnconfigure(2, weight=1) + title = ttk.Frame(ribbon) + title.grid(row=0, column=0, sticky="w", padx=(0, 18)) + ttk.Label(title, text="CCMA", style="AppTitle.TLabel").pack(anchor="w") + ttk.Label(title, text="// MEMBER ADMIN", style="Mono.TLabel").pack(anchor="w") + search = ttk.Frame(ribbon) + search.grid(row=0, column=1, sticky="w") + entry = ttk.Entry(search, textvariable=self.search_var, font=("TkDefaultFont", 11), width=40) + entry.grid(row=0, column=0, sticky="w") + entry.bind("", lambda _event: self.search()) + entry.bind("", lambda _event: entry.focus_set()) + ttk.Label(search, text="Name · E-Mail · Geburtsdatum · Mitgliedsnummer", style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 0) + ) + search_icon = self.icons.get("magnify", 24) + search_button = ttk.Button( + search, + text="Suchen", + image=search_icon, + compound="top", + width=14, + command=self.search, + ) + search_button.image = search_icon + search_button.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(6, 0)) + + ttk.Separator(ribbon, orient="vertical").grid( + row=0, + column=3, + sticky="ns", + padx=16, + ) + actions = ttk.Frame(ribbon) + actions.grid(row=0, column=4, sticky="e") + members_icon = self.icons.get("account-group", 24) or self.icons.get("account-multiple", 24) + members_button = ttk.Button( + actions, + text="Mitglieder", + image=members_icon, + compound="top", + width=14, + command=self.open_members, + ) + members_button.image = members_icon + members_button.pack(side="left", padx=(0, 6)) + new_icon = self.icons.get("account-plus", 24) + new_button = ttk.Button( + actions, + text="Neues Mitglied", + image=new_icon, + compound="top", + width=14, + command=self.new_member, + ) + new_button.image = new_icon + new_button.pack(side="left", padx=(0, 6)) + housekeeper_icon = self.icons.get("broom", 24) or self.icons.get("clipboard-check", 24) + self.housekeeper_button = ttk.Button( + actions, + text=f"Hausmeister ({len(self.findings)})", + image=housekeeper_icon, + compound="top", + width=14, + style="Accent.TButton", + command=self.open_housekeeper, + ) + self.housekeeper_button.image = housekeeper_icon + self.housekeeper_button.pack(side="left", padx=(0, 6)) + theme_icon = self.icons.get("theme-light-dark", 24) + theme_button = ttk.Button( + actions, + text="Light/Dark", + image=theme_icon, + compound="top", + width=14, + command=self.toggle_theme, + ) + theme_button.image = theme_icon + theme_button.pack(side="left", padx=(0, 6)) + options_icon = self.icons.get("cog", 24) + options_button = ttk.Button( + actions, + text="Optionen", + image=options_icon, + compound="top", + width=14, + command=self.open_options, + ) + options_button.image = options_icon + options_button.pack(side="left", padx=(0, 6)) + exit_icon = self.icons.get("logout", 24) or self.icons.get("exit-to-app", 24) + exit_button = ttk.Button( + actions, + text="Beenden", + image=exit_icon, + compound="top", + width=14, + command=getattr(self.master, "close", self.master.destroy), + ) + exit_button.image = exit_icon + exit_button.pack(side="left") + + def search(self) -> None: + query = self.search_var.get().strip() + if not query: + self.status_var.set("Bitte einen Suchbegriff eingeben.") + return + results = self.repository.search(query) + self.search_var.set("") + if len(results) == 1: + self.open_member(results[0].member_id) + self.status_var.set(f"Eindeutiger Treffer: {results[0].display_name}") + return + key = f"search:{self.search_counter}" + self.search_counter += 1 + tab = SearchResultsTab( + self.notebook, + query, + results, + self.open_member, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + f"Suche: {query[:20]}", + image=self.icons.get("format-list-text", 16), + icon_name="format-list-text", + ) + self.status_var.set(f"{len(results)} Treffer für {query}") + + def open_member(self, member_id: str) -> None: + key = f"member:{member_id}" + if self.tabs.focus(key): + return + member = self.repository.get_member(member_id) + tab = MemberTab( + self.notebook, + self.repository, + member_id, + on_close=lambda: self.tabs.close(key), + on_changed=self.refresh_overview, + ) + self.tabs.add( + key, + tab, + member.display_name or member.member_number, + image=self.icons.get("account", 16), + icon_name="account", + ) + + def open_members(self) -> None: + key = "members" + if self.tabs.focus(key): + return + tab = MembersTab( + self.notebook, + self.repository.list_members(), + self.open_member, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + "Mitglieder", + image=self.icons.get("account-group", 16) or self.icons.get("account-multiple", 16), + icon_name="account-group", + ) + + def new_member(self) -> None: + NewMemberDialog(self, self.repository, self._member_created) + + def _member_created(self, member: Member) -> None: + self.refresh_overview() + self.open_member(member.member_id) + self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.") + + def open_housekeeper(self) -> None: + key = "housekeeper" + if self.tabs.focus(key): + return + tab = HousekeeperTab( + self.notebook, + self.findings, + self.open_member, + self.run_housekeeper, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + "Hausmeister", + image=self.icons.get("broom", 16), + icon_name="broom", + ) + + def run_housekeeper(self) -> list[HousekeeperFinding]: + self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() + self.refresh_overview(run_housekeeper=False) + self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") + return self.findings + + def refresh_overview(self, *, run_housekeeper: bool = True) -> None: + if run_housekeeper: + self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() + self.dashboard.update_data(self.repository.member_count(), self.findings) + members_tab = self.tabs.tabs.get("members") + if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): + members_tab.refresh(self.repository.list_members()) + if self.housekeeper_button and self.housekeeper_button.winfo_exists(): + self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})") + + def toggle_theme(self) -> None: + self.config.theme_mode = "light" if self.config.theme_mode == "dark" else "dark" + self.config.save() + load_theme(self.master, self.config.theme_mode) + self._refresh_ribbon_icons() + self.status_var.set(f"Theme: {self.config.theme_mode}.") + + def open_options(self) -> None: + OptionsDialog(self, self.config, self.repository, self._options_saved) + + def _options_saved(self, store_changed: bool) -> None: + load_theme(self.master, self.config.theme_mode) + self._refresh_ribbon_icons() + if store_changed: + self.status_var.set("Mitglieder-Store geändert. Die Änderung wird beim Neustart aktiv.") + messagebox.showinfo( + "Neustart erforderlich", + "Der neue Mitglieder-Store wird beim nächsten Programmstart geöffnet.", + parent=self, + ) + else: + self.status_var.set("Optionen gespeichert.") + + def _refresh_ribbon_icons(self) -> None: + if self.ribbon and self.ribbon.winfo_exists(): + self.ribbon.destroy() + self.icons = IconStore(self) + self._build_ribbon() + self.tabs.refresh_icons(self.icons) + + def _show_validation_warning(self) -> None: + messagebox.showwarning( + "Datenprüfung", + "Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]), + parent=self, + ) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py new file mode 100644 index 0000000..3fced02 --- /dev/null +++ b/src/ccma/ui/member_tab.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import subprocess +import sys +import tkinter as tk +from collections.abc import Callable +from datetime import datetime +from tkinter import messagebox, ttk + +from ccma.domain.dates import age_label, date_input_hint, format_date_for_display +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS +from ccma.domain.models import Event +from ccma.storage.repository import MemberRepository, RepositoryError + + +class MemberTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + member_id: str, + on_close: Callable[[], None], + on_changed: Callable[[], None], + ): + super().__init__(master, padding=12) + self.repository = repository + self.member_id = member_id + self.on_close = on_close + self.on_changed = on_changed + self.member = repository.get_member(member_id) + self.variables: dict[str, tk.Variable] = {} + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + self.status_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 0) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid( + row=0, column=1, rowspan=2, sticky="e" + ) + + self.pane = ttk.Panedwindow(self, orient="horizontal") + self.pane.grid(row=1, column=0, sticky="nsew") + self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) + self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) + self.pane.add(self.details_pane, weight=2) + self.pane.add(self.timeline_pane, weight=3) + self._build_details(self.details_pane) + self._build_timeline(self.timeline_pane) + self._pane_position_initialized = False + self.pane.bind("", self._set_initial_pane_position, add="+") + + def _set_initial_pane_position(self, event: tk.Event | None = None) -> None: + if self._pane_position_initialized: + return + try: + width = int(getattr(event, "width", 0)) or self.pane.winfo_width() + if width > 1: + self.pane.sashpos(0, max(360, int(width * 0.4))) + self._pane_position_initialized = True + except tk.TclError: + return + + def _build_details(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + notebook = ttk.Notebook(parent) + notebook.grid(row=0, column=0, sticky="nsew") + data_tab = ttk.Frame(notebook, padding=16) + contribution_tab = ttk.Frame(notebook, padding=16) + documents_tab = ttk.Frame(notebook, padding=16) + notebook.add(data_tab, text="Stammdaten") + notebook.add(contribution_tab, text="Beiträge") + notebook.add(documents_tab, text="Dokumente") + + fields = [ + ("Mitgliedsnummer", "member_number"), + ("Vorname", "first_name"), + ("Nachname", "last_name"), + ("E-Mail-Adresse", "email"), + (f"Geburtsdatum ({date_input_hint()})", "birth_date"), + (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), + (f"Mitglied seit ({date_input_hint()})", "membership_started_at"), + ] + for row, (label, key) in enumerate(fields): + variable = tk.StringVar() + self.variables[key] = variable + ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + if key == "birth_date": + birth_row = ttk.Frame(data_tab) + birth_row.grid(row=row, column=1, sticky="ew", pady=5) + birth_row.columnconfigure(0, weight=1) + ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew") + self.age_var = tk.StringVar(value="Alter: —") + ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(10, 0) + ) + variable.trace_add( + "write", lambda *_args, source=variable: self.age_var.set(age_label(source.get())) + ) + else: + ttk.Entry(data_tab, textvariable=variable, width=42).grid( + row=row, column=1, sticky="ew", pady=5 + ) + self.variables["status"] = tk.StringVar() + ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Combobox( + data_tab, + textvariable=self.variables["status"], + values=list(STATUS_LABELS), + state="readonly", + width=39, + ).grid(row=len(fields), column=1, sticky="ew", pady=5) + self.variables["notes"] = tk.StringVar() + ttk.Label(data_tab, text="Interne Notiz").grid( + row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12) + ) + ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid( + row=len(fields) + 1, column=1, sticky="ew", pady=5 + ) + data_tab.columnconfigure(1, weight=1) + ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid( + row=len(fields) + 2, column=1, sticky="e", pady=(18, 0) + ) + + contribution_tab.columnconfigure(0, weight=1) + contribution_tab.rowconfigure(1, weight=1) + self.contribution_summary = tk.StringVar() + ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.claims = ttk.Treeview( + contribution_tab, columns=("title", "due", "amount", "status"), show="headings" + ) + for key, title, width in ( + ("title", "Forderung", 220), + ("due", "Fällig", 100), + ("amount", "Betrag", 90), + ("status", "Status", 110), + ): + self.claims.heading(key, text=title) + self.claims.column(key, width=width, anchor="w") + self.claims.grid(row=1, column=0, sticky="nsew") + + documents_tab.columnconfigure(0, weight=1) + documents_tab.rowconfigure(1, weight=1) + ttk.Button(documents_tab, text="Dateiordner öffnen", command=self._open_files).grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.documents = tk.Listbox(documents_tab, borderwidth=0, highlightthickness=0) + self.documents.grid(row=1, column=0, sticky="nsew") + + def _build_timeline(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) + self.timeline = ttk.Treeview( + parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview" + ) + self.timeline.heading("time", text="Zeit") + self.timeline.heading("summary", text="Ereignis") + self.timeline.column("time", width=135, stretch=False) + self.timeline.column("summary", width=320, stretch=True) + self.timeline.grid(row=1, column=0, sticky="nsew") + compose = ttk.Frame(parent) + compose.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + compose.columnconfigure(0, weight=1) + self.comment_var = tk.StringVar() + comment = ttk.Entry(compose, textvariable=self.comment_var) + comment.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + comment.bind("", lambda _event: self._add_comment()) + ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) + + def refresh(self) -> None: + self.member = self.repository.get_member(self.member_id) + self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}") + self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper())) + date_fields = {"birth_date", "accepted_at", "membership_started_at"} + for key, variable in self.variables.items(): + value = getattr(self.member, key) + variable.set(format_date_for_display(value) if key in date_fields else value) + self._refresh_events() + self._refresh_contributions() + self._refresh_documents() + + def _refresh_events(self) -> None: + self.timeline.delete(*self.timeline.get_children()) + try: + events = self.repository.get_events(self.member_id) + except RepositoryError as exc: + messagebox.showerror("Chronik beschädigt", str(exc), parent=self) + return + for event in reversed(events): + self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) + + def _refresh_contributions(self) -> None: + data = self.repository.get_contributions(self.member_id) + self.claims.delete(*self.claims.get_children()) + for claim in data.claims: + self.claims.insert( + "", + "end", + values=( + claim.get("title", "Beitrag"), + claim.get("due_date", ""), + claim.get("amount", ""), + claim.get("status", "open"), + ), + ) + self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") + + def _refresh_documents(self) -> None: + self.documents.delete(0, "end") + root = self.repository.members_root / self.member_id / "files" + for path in sorted(root.rglob("*")): + if path.is_file(): + self.documents.insert("end", str(path.relative_to(root))) + + def _save(self) -> None: + for key, variable in self.variables.items(): + setattr(self.member, key, variable.get().strip()) + try: + self.repository.save_member(self.member) + except RepositoryError as exc: + messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) + return + self.refresh() + self.on_changed() + + def _add_comment(self) -> None: + text = self.comment_var.get().strip() + if not text: + return + self.repository.append_event( + self.member_id, + event_type="board_comment", + summary=text, + actor_type="user", + actor_name="Vorstand", + ) + self.comment_var.set("") + self._refresh_events() + + def _open_files(self) -> None: + path = self.repository.members_root / self.member_id / "files" + if sys.platform == "win32": + subprocess.Popen(["explorer", str(path)]) + elif sys.platform == "darwin": + subprocess.Popen(["open", str(path)]) + else: + subprocess.Popen(["xdg-open", str(path)]) + + +def _format_timestamp(event: Event) -> str: + try: + return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M") + except ValueError: + return event.timestamp[:16] + + +def _event_label(event: Event) -> str: + if event.actor_type == "system": + return f"[AUTO] {event.summary}" + return event.summary diff --git a/src/ccma/ui/monitors.py b/src/ccma/ui/monitors.py new file mode 100644 index 0000000..01603d8 --- /dev/null +++ b/src/ccma/ui/monitors.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import re +import tkinter as tk +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class MonitorBounds: + x: int + y: int + width: int + height: int + primary: bool = False + + @property + def center(self) -> tuple[int, int]: + return self.x + self.width // 2, self.y + self.height // 2 + + def as_tuple(self) -> tuple[int, int, int, int]: + return self.x, self.y, self.width, self.height + + +def available_monitors(root: tk.Misc | None = None) -> list[MonitorBounds]: + try: + from screeninfo import get_monitors + + monitors = [ + MonitorBounds( + int(monitor.x), + int(monitor.y), + int(monitor.width), + int(monitor.height), + bool(getattr(monitor, "is_primary", False)), + ) + for monitor in get_monitors() + ] + if monitors: + return monitors + except Exception: + pass + if root is None: + return [] + return [ + MonitorBounds( + int(root.winfo_vrootx()), + int(root.winfo_vrooty()), + int(root.winfo_vrootwidth()), + int(root.winfo_vrootheight()), + True, + ) + ] + + +def preferred_monitor( + root: tk.Misc, + saved_bounds: tuple[int, int, int, int] | None = None, +) -> MonitorBounds: + monitors = available_monitors(root) + if saved_bounds: + for monitor in monitors: + if monitor.as_tuple() == saved_bounds: + return monitor + return next((monitor for monitor in monitors if monitor.primary), monitors[0]) + + +def monitor_for_geometry(root: tk.Misc, geometry: str) -> MonitorBounds: + monitors = available_monitors(root) + parsed = parse_geometry(geometry) + if not parsed: + return next((monitor for monitor in monitors if monitor.primary), monitors[0]) + width, height, x, y = parsed + center_x, center_y = x + width // 2, y + height // 2 + containing = [ + monitor + for monitor in monitors + if monitor.x <= center_x < monitor.x + monitor.width + and monitor.y <= center_y < monitor.y + monitor.height + ] + if containing: + return containing[0] + return min(monitors, key=lambda monitor: _distance_squared(monitor.center, (center_x, center_y))) + + +def centered_geometry(width: int, height: int, monitor: MonitorBounds) -> str: + fitted_width = min(width, max(640, monitor.width - 80)) + fitted_height = min(height, max(480, monitor.height - 80)) + x = monitor.x + (monitor.width - fitted_width) // 2 + y = monitor.y + (monitor.height - fitted_height) // 2 + return format_geometry(fitted_width, fitted_height, x, y) + + +def ensure_visible_geometry(geometry: str, monitor: MonitorBounds) -> str: + parsed = parse_geometry(geometry) + if not parsed: + return centered_geometry(1500, 860, monitor) + width, height, x, y = parsed + width = min(width, max(640, monitor.width - 40)) + height = min(height, max(480, monitor.height - 40)) + x = min(max(x, monitor.x), monitor.x + monitor.width - width) + y = min(max(y, monitor.y), monitor.y + monitor.height - height) + return format_geometry(width, height, x, y) + + +def parse_geometry(geometry: str) -> tuple[int, int, int, int] | None: + match = re.fullmatch(r"(\d+)x(\d+)([+-]\d+)([+-]\d+)", geometry.strip()) + if not match: + return None + return tuple(int(value) for value in match.groups()) # type: ignore[return-value] + + +def format_geometry(width: int, height: int, x: int, y: int) -> str: + return f"{width}x{height}{x:+d}{y:+d}" + + +def _distance_squared(left: tuple[int, int], right: tuple[int, int]) -> int: + return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py new file mode 100644 index 0000000..63746b5 --- /dev/null +++ b/src/ccma/ui/options_dialog.py @@ -0,0 +1,374 @@ +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("", 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 diff --git a/src/ccma/ui/splash.py b/src/ccma/ui/splash.py new file mode 100644 index 0000000..052ce76 --- /dev/null +++ b/src/ccma/ui/splash.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import threading +import tkinter as tk +from collections.abc import Callable +from dataclasses import dataclass +from queue import Empty, Queue +from tkinter import ttk + +from ccma import __version__ +from ccma.domain.models import HousekeeperFinding +from ccma.services.housekeeper import Housekeeper, HousekeeperSettings +from ccma.storage.repository import MemberRepository +from ccma.ui.monitors import MonitorBounds, preferred_monitor + + +@dataclass(slots=True) +class StartupResult: + repository: MemberRepository + validation_errors: list[str] + findings: list[HousekeeperFinding] + + +class SplashScreen(tk.Toplevel): + def __init__( + self, + master: tk.Tk, + repository: MemberRepository, + on_complete: Callable[[StartupResult], None], + on_error: Callable[[Exception], None], + *, + run_housekeeper: bool = True, + monitor: MonitorBounds | None = None, + housekeeper_settings: HousekeeperSettings | None = None, + ): + super().__init__(master) + self.repository = repository + self.on_complete = on_complete + self.on_error = on_error + self.run_housekeeper = run_housekeeper + self.monitor = monitor + self.housekeeper_settings = housekeeper_settings + self._messages: Queue[tuple[str, object]] = Queue() + self.overrideredirect(True) + self.resizable(False, False) + self.configure(background="#0b1117") + self.canvas: tk.Canvas + self.status_item: int + self._build_ui() + self._center() + self.after(120, self._start) + + def _build_ui(self) -> None: + width, height = 620, 330 + self.geometry(f"{width}x{height}") + self.canvas = tk.Canvas( + self, + width=width, + height=height, + highlightthickness=0, + background="#0b1117", + ) + self.canvas.pack(fill="both", expand=True) + self.canvas.create_rectangle(0, 0, width, 6, fill="#00d084", outline="") + self.canvas.create_text( + 34, + 58, + anchor="w", + text="CCMA", + fill="#00d084", + font=("TkFixedFont", 30, "bold"), + ) + self.canvas.create_text( + 34, + 101, + anchor="w", + text="CHAOTIC CREATURE MEMBER ADMINISTRATION", + fill="#d7e0e8", + font=("TkFixedFont", 12, "bold"), + ) + self.canvas.create_text( + 34, + 139, + anchor="w", + text="Chaos Computer Club Mannheim e.V.", + fill="#7f8e9b", + font=("TkDefaultFont", 10), + ) + self.canvas.create_text( + width - 34, 58, anchor="e", text=f"v{__version__}", fill="#7f8e9b", font=("TkFixedFont", 10) + ) + self.status_item = self.canvas.create_text( + 34, + 245, + anchor="w", + text="Initialisiere …", + fill="#d7e0e8", + font=("TkFixedFont", 10), + ) + self.progress = ttk.Progressbar(self, mode="indeterminate") + self.canvas.create_window(34, 280, anchor="w", width=552, window=self.progress) + + def _center(self) -> None: + self.update_idletasks() + width, height = self.winfo_width(), self.winfo_height() + monitor = self.monitor or preferred_monitor(self) + pointer_x, pointer_y = monitor.center + x, y = centered_position( + width=width, + height=height, + pointer_x=pointer_x, + pointer_y=pointer_y, + screen_x=monitor.x, + screen_y=monitor.y, + screen_width=monitor.width, + screen_height=monitor.height, + ) + self.geometry(f"{width}x{height}+{x}+{y}") + + def _start(self) -> None: + self.progress.start(10) + + def worker() -> None: + try: + self._messages.put(("status", "Öffne Mitglieder-Store …")) + self.repository.initialize() + self._messages.put(("status", "Validiere Mitgliederakten …")) + errors = self.repository.validate() + self._messages.put(("status", "Baue Suchindex …")) + self.repository.list_members() + findings = [] + if self.run_housekeeper: + self._messages.put(("status", "Starte Hausmeister …")) + findings = Housekeeper(self.repository, self.housekeeper_settings).run() + result = StartupResult(self.repository, errors, findings) + self._messages.put(("result", result)) + except Exception as exc: + self._messages.put(("error", exc)) + + threading.Thread(target=worker, name="ccma-startup", daemon=True).start() + self.after(30, self._poll_messages) + + def _poll_messages(self) -> None: + keep_polling = True + try: + while True: + kind, payload = self._messages.get_nowait() + if kind == "status": + self._set_status(str(payload)) + elif kind == "result": + keep_polling = False + self._finish(payload) # type: ignore[arg-type] + elif kind == "error": + keep_polling = False + self._fail(payload) # type: ignore[arg-type] + except Empty: + pass + if keep_polling and self.winfo_exists(): + self.after(30, self._poll_messages) + + def _finish(self, result: StartupResult) -> None: + self.progress.stop() + self._set_status(f"Bereit · {len(result.findings)} Vorgänge benötigen Aufmerksamkeit") + self.after(350, lambda: self._complete(result)) + + def _set_status(self, text: str) -> None: + self.canvas.itemconfigure(self.status_item, text=text) + + def _complete(self, result: StartupResult) -> None: + self.destroy() + self.on_complete(result) + + def _fail(self, error: Exception) -> None: + self.progress.stop() + self.destroy() + self.on_error(error) + + +def centered_position( + *, + width: int, + height: int, + pointer_x: int, + pointer_y: int, + screen_x: int, + screen_y: int, + screen_width: int, + screen_height: int, +) -> tuple[int, int]: + """Center around the pointer and keep the complete window on the virtual desktop.""" + maximum_x = screen_x + max(0, screen_width - width) + maximum_y = screen_y + max(0, screen_height - height) + x = min(max(pointer_x - width // 2, screen_x), maximum_x) + y = min(max(pointer_y - height // 2, screen_y), maximum_y) + return x, y diff --git a/src/ccma/ui/theme.py b/src/ccma/ui/theme.py new file mode 100644 index 0000000..da3e929 --- /dev/null +++ b/src/ccma/ui/theme.py @@ -0,0 +1,77 @@ +from pathlib import Path +from tkinter import TclError, ttk + + +def load_theme(root, mode: str) -> str: + style = ttk.Style(root) + variant = "light" if mode == "light" else "dark" + theme_name = f"forest-{variant}" + theme_path = ( + Path(__file__).resolve().parent.parent / "assets" / "themes" / "forest" / f"forest-{variant}.tcl" + ) + if theme_path.exists(): + try: + if theme_name not in style.theme_names(): + root.tk.call("source", str(theme_path)) + style.theme_use(theme_name) + _configure_ccma_styles(style, variant) + return theme_name + except TclError: + pass + style.theme_use("clam") + _configure_fallback(style, variant) + _configure_ccma_styles(style, variant) + return "clam" + + +def _configure_fallback(style: ttk.Style, variant: str) -> None: + dark = variant == "dark" + background = "#14181f" if dark else "#f4f6f8" + surface = "#20262f" if dark else "#ffffff" + foreground = "#e6edf3" if dark else "#20252b" + accent = "#00a884" if dark else "#087f5b" + style.configure(".", background=background, foreground=foreground) + style.configure("TFrame", background=background) + style.configure("TLabel", background=background, foreground=foreground) + style.configure("TButton", background=surface, foreground=foreground) + style.configure("TEntry", fieldbackground=surface, foreground=foreground) + style.configure("Treeview", background=surface, fieldbackground=surface, foreground=foreground) + style.map("Treeview", background=[("selected", accent)], foreground=[("selected", "#ffffff")]) + + +def _configure_ccma_styles(style: ttk.Style, variant: str) -> None: + dark = variant == "dark" + background = "#1b1f23" if dark else "#ffffff" + foreground = "#f0f4f8" if dark else "#202124" + muted = "#9aa4ad" if dark else "#5f6368" + accent = "#00d084" if dark else "#087f5b" + warning = "#ffb454" + danger = "#ff6b6b" + style.configure("Ribbon.TFrame", padding=(12, 9)) + style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold")) + style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold")) + style.configure("Mono.TLabel", font=("TkFixedFont", 10), foreground=muted) + style.configure("Muted.TLabel", foreground=muted) + style.configure("Status.TLabel", font=("TkFixedFont", 9), foreground=muted) + style.configure("TimelineHeader.TLabel", font=("TkFixedFont", 11, "bold"), foreground=accent) + style.configure("Error.TLabel", foreground=danger) + style.configure("Warning.TLabel", foreground=warning) + style.configure("Accent.TButton", font=("TkDefaultFont", 10, "bold")) + style.configure("Card.TFrame", background=background, relief="solid", borderwidth=1) + style.configure("CardTitle.TLabel", background=background, foreground=muted, font=("TkFixedFont", 10)) + style.configure( + "CardValue.TLabel", background=background, foreground=foreground, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "CardError.TLabel", background=background, foreground=danger, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "CardWarning.TLabel", background=background, foreground=warning, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "Timeline.Treeview", + rowheight=42, + background=background, + fieldbackground=background, + foreground=foreground, + ) diff --git a/src/ccma/ui/window_state.py b/src/ccma/ui/window_state.py new file mode 100644 index 0000000..d9874ea --- /dev/null +++ b/src/ccma/ui/window_state.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import tkinter as tk + + +def is_maximized(window: tk.Tk) -> bool: + try: + if window.state() == "zoomed": + return True + except tk.TclError: + pass + try: + return bool(int(window.attributes("-zoomed"))) + except (tk.TclError, TypeError, ValueError): + return False + + +def maximize(window: tk.Tk) -> bool: + try: + window.state("zoomed") + if is_maximized(window): + return True + except tk.TclError: + pass + try: + window.attributes("-zoomed", True) + return bool(int(window.attributes("-zoomed"))) + except (tk.TclError, TypeError, ValueError): + return False diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py new file mode 100644 index 0000000..aeb91a8 --- /dev/null +++ b/src/ccma/ui/work_tabs.py @@ -0,0 +1,248 @@ +import tkinter as tk +from collections import Counter +from collections.abc import Callable +from tkinter import ttk + +from ccma.domain.dates import format_date_for_display +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member + + +class DashboardTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + member_count: int, + findings: list[HousekeeperFinding], + on_housekeeper: Callable[[], None], + ): + super().__init__(master, padding=24) + self.member_count = member_count + self.findings = findings + self.on_housekeeper = on_housekeeper + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + ttk.Label(self, text="SYSTEM OVERVIEW", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + ttk.Label(self, text="Mitgliederverwaltung · lokaler File-Store", style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 22) + ) + cards = ttk.Frame(self) + cards.grid(row=2, column=0, sticky="ew") + counts = Counter(finding.severity for finding in self.findings) + values = [ + ("MITGLIEDER", str(self.member_count), ""), + ("ACTION REQUIRED", str(counts["error"]), "CardError.TLabel"), + ("DUE SOON", str(counts["warning"] + counts["info"]), "CardWarning.TLabel"), + ("DATA INTEGRITY", "OK", ""), + ] + for column, (label, value, style) in enumerate(values): + card = ttk.Frame(cards, style="Card.TFrame", padding=18) + card.grid(row=0, column=column, sticky="nsew", padx=(0, 10)) + cards.columnconfigure(column, weight=1) + ttk.Label(card, text=label, style="CardTitle.TLabel").pack(anchor="w") + ttk.Label(card, text=value, style=style or "CardValue.TLabel").pack(anchor="w", pady=(8, 0)) + ttk.Button(self, text="Hausmeister öffnen", style="Accent.TButton", command=self.on_housekeeper).grid( + row=3, column=0, sticky="w", pady=(24, 0) + ) + + def update_data(self, member_count: int, findings: list[HousekeeperFinding]) -> None: + self.member_count = member_count + self.findings = findings + for child in self.winfo_children(): + child.destroy() + self._build_ui() + + +class SearchResultsTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + query: str, + members: list[Member], + on_open: Callable[[str], None], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.query = query + self.members = members + self.on_open = on_open + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text=f'Suche: "{self.query}"', style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid( + row=1, column=0, sticky="w" + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) + tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings") + for key, title, width in ( + ("number", "Nummer", 90), + ("name", "Name", 220), + ("email", "E-Mail-Adresse", 260), + ("birth", "Geburtsdatum", 110), + ("status", "Status", 160), + ): + tree.heading(key, text=title) + tree.column(key, width=width, anchor="w") + tree.grid(row=1, column=0, sticky="nsew") + for member in self.members: + tree.insert( + "", + "end", + iid=member.member_id, + values=( + member.member_number, + member.display_name, + member.email, + format_date_for_display(member.birth_date), + MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), + ), + ) + tree.bind("", lambda _event: self._open_selected(tree)) + tree.bind("", lambda _event: self._open_selected(tree)) + + def _open_selected(self, tree: ttk.Treeview) -> None: + selected = tree.selection() + if selected: + self.on_open(selected[0]) + + +class MembersTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + members: list[Member], + on_open: Callable[[str], None], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.members = members + self.on_open = on_open + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + self.count_var = tk.StringVar() + ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) + self.tree = ttk.Treeview( + self, columns=("number", "name", "email", "birth", "status"), show="headings" + ) + for key, title, width in ( + ("number", "Nummer", 110), + ("name", "Name", 230), + ("email", "E-Mail-Adresse", 270), + ("birth", "Geburtsdatum", 120), + ("status", "Status", 170), + ): + self.tree.heading(key, text=title) + self.tree.column(key, width=width, anchor="w") + self.tree.grid(row=1, column=0, sticky="nsew") + self.tree.bind("", lambda _event: self._open_selected()) + self.tree.bind("", lambda _event: self._open_selected()) + self.refresh(self.members) + + def refresh(self, members: list[Member]) -> None: + self.members = members + self.tree.delete(*self.tree.get_children()) + self.count_var.set(f"{len(members)} Mitglieder") + for member in members: + self.tree.insert( + "", + "end", + iid=member.member_id, + values=( + member.member_number, + member.display_name, + member.email, + format_date_for_display(member.birth_date), + MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), + ), + ) + + def _open_selected(self) -> None: + selected = self.tree.selection() + if selected: + self.on_open(selected[0]) + + +class HousekeeperTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + findings: list[HousekeeperFinding], + on_open_member: Callable[[str], None], + on_refresh: Callable[[], list[HousekeeperFinding]], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.findings = findings + self.on_open_member = on_open_member + self.on_refresh = on_refresh + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label( + header, text="Prüfend, keine Aktionen werden automatisch ausgeführt", style="Mono.TLabel" + ).grid(row=1, column=0, sticky="w") + ttk.Button(header, text="Neu prüfen", command=self.refresh).grid( + row=0, column=1, rowspan=2, padx=(0, 8) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2) + self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings") + for key, title, width in ( + ("severity", "Level", 90), + ("title", "Vorgang", 330), + ("detail", "Details", 390), + ("due", "Fällig", 110), + ): + self.tree.heading(key, text=title) + self.tree.column(key, width=width, anchor="w") + self.tree.grid(row=1, column=0, sticky="nsew") + self.tree.bind("", lambda _event: self._open_selected()) + self._render() + + def refresh(self) -> None: + self.findings = self.on_refresh() + self._render() + + def _render(self) -> None: + self.tree.delete(*self.tree.get_children()) + self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge") + for index, finding in enumerate(self.findings): + self.tree.insert( + "", + "end", + iid=str(index), + values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""), + ) + + def _open_selected(self) -> None: + selected = self.tree.selection() + if selected: + self.on_open_member(self.findings[int(selected[0])].member_id) diff --git a/src/ccma/version.py b/src/ccma/version.py new file mode 100644 index 0000000..b89c6d4 --- /dev/null +++ b/src/ccma/version.py @@ -0,0 +1,31 @@ +from importlib.metadata import PackageNotFoundError, version +from importlib.resources import files +from pathlib import Path + + +def _checkout_version() -> str | None: + current = Path(__file__).resolve() + for parent in current.parents: + candidate = parent / "VERSION" + if candidate.is_file(): + value = candidate.read_text(encoding="utf-8").strip() + if value: + return value + return None + + +def get_version() -> str: + """Return the checkout VERSION, or installed package metadata as fallback.""" + checkout = _checkout_version() + if checkout: + return checkout + try: + bundled = files("ccma").joinpath("VERSION").read_text(encoding="utf-8").strip() + if bundled: + return bundled + except (FileNotFoundError, ModuleNotFoundError): + pass + try: + return version("ccma") + except PackageNotFoundError: + return "0+unknown" diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..53cca0a --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,9 @@ +from ccma import __version__ +from ccma.ui.changelog_view import load_changelog + + +def test_changelog_contains_current_version() -> None: + entries = load_changelog() + assert entries + assert entries[0]["version"] == __version__ + assert entries[0]["changes"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3523b2a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,37 @@ +import json + +from ccma.config import AppConfig, load_config + + +def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config")) + expected = AppConfig( + store_path=str(tmp_path / "members"), + gnucash_path=str(tmp_path / "club.gnucash"), + theme_mode="light", + run_housekeeper_on_startup=False, + birthday_days_before=10, + birthday_days_after=3, + anniversary_days_before=21, + anniversary_days_after=5, + anniversary_intervals="30D;2M;1Y;10Y", + window_geometry="1200x800-1800+40", + window_state="maximized", + monitor_bounds=(-1920, 0, 1920, 1080), + ) + expected.save() + + loaded = load_config() + assert loaded == expected + raw = json.loads(expected.path.read_text(encoding="utf-8")) + assert raw["schema_version"] == 1 + assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080] + + +def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("CCMA_CONFIG_DIR", raising=False) + monkeypatch.delenv("CCMA_STORE", raising=False) + monkeypatch.setenv("C3MA_CONFIG_DIR", str(tmp_path / "legacy-config")) + monkeypatch.setenv("C3MA_STORE", str(tmp_path / "legacy-store")) + + assert load_config().store_path == str(tmp_path / "legacy-store") diff --git a/tests/test_dates.py b/tests/test_dates.py new file mode 100644 index 0000000..fea9d34 --- /dev/null +++ b/tests/test_dates.py @@ -0,0 +1,63 @@ +from datetime import date + +import pytest + +from ccma.domain.dates import ( + DateValidationError, + age_label, + calculate_age, + format_date_for_display, + normalize_date_input, + parse_date_input, + parse_iso_date, + validate_birth_date, + validate_member_dates, +) + + +def test_iso_dates_are_strict_and_real() -> None: + assert parse_iso_date("2024-02-29", "Datum") == date(2024, 2, 29) + for value in ("29.02.2024", "2024-2-29", "2023-02-29", "irgendwas"): + with pytest.raises(DateValidationError): + parse_iso_date(value, "Datum") + + +def test_date_input_accepts_german_and_iso_formats() -> None: + expected = date(2024, 2, 29) + assert parse_date_input("29.02.2024", "Datum") == expected + assert parse_date_input("2024-02-29", "Datum") == expected + assert normalize_date_input("29.02.2024", "Datum") == "2024-02-29" + + +def test_date_display_uses_system_pattern(monkeypatch) -> None: + monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y") + assert format_date_for_display("2024-02-29") == "29.02.2024" + monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d") + assert format_date_for_display("2024-02-29") == "2024-02-29" + + +def test_birth_date_checks_future_and_plausibility() -> None: + today = date(2026, 6, 21) + assert validate_birth_date("2000-06-22", today=today) == date(2000, 6, 22) + with pytest.raises(DateValidationError, match="Zukunft"): + validate_birth_date("2026-06-22", today=today) + with pytest.raises(DateValidationError, match="120"): + validate_birth_date("1900-01-01", today=today) + + +def test_member_dates_must_be_chronological() -> None: + with pytest.raises(DateValidationError, match="Aufnahmebeschluss"): + validate_member_dates( + birth_date="2000-01-01", + accepted_at="2020-01-02", + membership_started_at="2020-01-01", + today=date(2026, 6, 21), + ) + + +def test_age_calculation_and_label() -> None: + today = date(2026, 6, 21) + assert calculate_age(date(2000, 6, 21), today) == 26 + assert calculate_age(date(2000, 6, 22), today) == 25 + assert age_label("2000-06-21", today=today) == "Alter: 26 Jahre" + assert age_label("nein", today=today) == "UNGÜLTIGES DATUM" diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py new file mode 100644 index 0000000..e6b548b --- /dev/null +++ b/tests/test_housekeeper.py @@ -0,0 +1,93 @@ +from datetime import date + +from ccma.domain.models import ContributionData +from ccma.services.housekeeper import Housekeeper, HousekeeperSettings +from ccma.storage.repository import MemberRepository + + +def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Test", last_name="Person") + member.status = "accepted_pending_payment" + member.accepted_at = "2026-01-01" + repository.save_member(member) + repository.save_contributions( + member.member_id, + ContributionData( + claims=[ + { + "claim_id": "claim-1", + "title": "Mitgliedsbeitrag 2026", + "amount": "150.00", + "due_date": "2026-01-31", + "status": "open", + } + ] + ), + ) + + findings = Housekeeper(repository).run(today=date(2026, 2, 10)) + assert {finding.code for finding in findings} == {"initial_payment_overdue", "claim_overdue"} + + +def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + dates = ("1990-06-20", "1990-06-21", "1990-06-22") + for index, birth_date in enumerate(dates): + member = repository.create_member( + first_name=f"Birthday{index}", + last_name="Member", + birth_date=birth_date, + ) + member.status = "active" + repository.save_member(member) + settings = HousekeeperSettings.from_values( + birthday_days_before=2, + birthday_days_after=2, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + ) + + findings = [ + finding + for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + if finding.code == "birthday" + ] + assert {finding.title for finding in findings} == { + "Birthday0 Member hatte vor 1 Tag Geburtstag", + "Birthday1 Member hat heute Geburtstag", + "Birthday2 Member hat in 1 Tag Geburtstag", + } + + +def test_housekeeper_reports_day_month_and_year_anniversaries(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + starts = ("2026-05-22", "2026-04-21", "2025-06-21", "2016-06-22") + for index, started_at in enumerate(starts): + member = repository.create_member(first_name=f"Anniversary{index}", last_name="Member") + member.status = "active" + member.membership_started_at = started_at + repository.save_member(member) + settings = HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=2, + anniversary_days_after=2, + anniversary_intervals="30D;2M;1Y;10Y", + ) + + findings = [ + finding + for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + if finding.code == "membership_anniversary" + ] + assert {finding.title for finding in findings} == { + "Anniversary0 Member hat heute 30-Tage-Mitgliedsjubiläum", + "Anniversary1 Member hat heute 2-Monats-Mitgliedsjubiläum", + "Anniversary2 Member hat heute 1-jähriges Mitgliedsjubiläum", + "Anniversary3 Member hat in 1 Tag 10-jähriges Mitgliedsjubiläum", + } diff --git a/tests/test_intervals.py b/tests/test_intervals.py new file mode 100644 index 0000000..4d3fa01 --- /dev/null +++ b/tests/test_intervals.py @@ -0,0 +1,27 @@ +from datetime import date + +import pytest + +from ccma.services.intervals import ( + IntervalValidationError, + normalize_anniversary_intervals, + parse_anniversary_intervals, +) + + +def test_intervals_accept_commas_semicolons_and_units() -> None: + intervals = parse_anniversary_intervals("30d, 2M;1;10Y;10y") + assert [interval.token for interval in intervals] == ["30D", "2M", "1Y", "10Y"] + assert normalize_anniversary_intervals("30d, 2M;1Y") == "30D;2M;1Y" + + +def test_month_and_year_intervals_use_calendar_arithmetic() -> None: + intervals = parse_anniversary_intervals("1M;1Y") + assert intervals[0].target_date(date(2024, 1, 31)) == date(2024, 2, 29) + assert intervals[1].target_date(date(2024, 2, 29)) == date(2025, 2, 28) + + +@pytest.mark.parametrize("value", ["", "D", "0D", "-1Y", "101Y"]) +def test_invalid_intervals_are_rejected(value) -> None: + with pytest.raises(IntervalValidationError): + parse_anniversary_intervals(value) diff --git a/tests/test_monitors.py b/tests/test_monitors.py new file mode 100644 index 0000000..ec6cf39 --- /dev/null +++ b/tests/test_monitors.py @@ -0,0 +1,13 @@ +from ccma.ui.monitors import MonitorBounds, centered_geometry, ensure_visible_geometry, parse_geometry + + +def test_centered_geometry_supports_monitor_left_of_primary() -> None: + monitor = MonitorBounds(-1920, 0, 1920, 1080) + geometry = centered_geometry(620, 330, monitor) + assert parse_geometry(geometry) == (620, 330, -1270, 375) + + +def test_saved_geometry_is_clamped_to_selected_monitor() -> None: + monitor = MonitorBounds(1920, 0, 1920, 1080) + geometry = ensure_visible_geometry("1500x860-1600+100", monitor) + assert parse_geometry(geometry) == (1500, 860, 1920, 100) diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..4f4a94a --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,143 @@ +import json + +import pytest + +from ccma.storage.repository import ( + MemberRepository, + RepositoryError, + format_member_number, + validate_member_number_pattern, +) + + +def test_repository_creates_transparent_member_record(tmp_path) -> None: + repository = MemberRepository(tmp_path / "store") + repository.initialize() + + member = repository.create_member( + first_name="Ada", + last_name="Lovelace", + email="ada@example.org", + birth_date="1990-12-10", + member_number="0042", + ) + + member_dir = repository.members_root / member.member_id + assert (member_dir / "member.json").is_file() + assert (member_dir / "contributions.json").is_file() + assert (member_dir / "events.jsonl").is_file() + assert (member_dir / "files").is_dir() + assert repository.validate() == [] + + raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8")) + assert raw["person"]["first_name"] == "Ada" + assert raw["schema_version"] == 1 + + +def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member( + first_name="Jörg", + last_name="Müller", + email="joerg.mueller@example.org", + birth_date="1990-04-23", + member_number="C3-007", + ) + + for query in ("Jorg Muller", "mueller@example.org", "C3-007", "23.04.1990"): + assert [result.member_id for result in repository.search(query)] == [member.member_id] + + +def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Alice", last_name="Example", email="old@example.org") + member.email = "new@example.org" + repository.save_member(member) + repository.append_event( + member.member_id, + event_type="board_comment", + summary="Telefonisch erreicht", + actor_type="user", + actor_name="Vorstand", + ) + + events = repository.get_events(member.member_id) + assert [event.event_type for event in events] == [ + "member_created", + "member_data_changed", + "board_comment", + ] + assert "E-Mail-Adresse" in events[1].summary + assert "old@example.org" not in events[1].summary + assert "new@example.org" not in events[1].summary + + +def test_status_change_audit_contains_old_and_new_status(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Status", last_name="Test") + member.status = "active" + repository.save_member(member) + + event = repository.get_events(member.member_id)[-1] + assert event.summary == "Mitgliedsdaten geändert: Status von ANTRAG zu AKTIV" + + +def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Local", last_name="Date", birth_date="31.12.2000") + assert member.birth_date == "2000-12-31" + assert repository.get_member(member.member_id).birth_date == "2000-12-31" + with pytest.raises(RepositoryError, match="gültiges Datum"): + repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000") + + +def test_member_path_rejects_traversal(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + with pytest.raises(RepositoryError): + repository.get_member("../outside") + + +def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + + assert repository.preview_member_number() == "CCMA-0001" + assert repository.preview_member_number() == "CCMA-0001" + first = repository.create_member(first_name="First", last_name="Member") + second = repository.create_member(first_name="Second", last_name="Member") + + assert first.member_number == "CCMA-0001" + assert second.member_number == "CCMA-0002" + assert repository.preview_member_number() == "CCMA-0003" + + +def test_custom_pattern_and_manual_mode(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + repository.save_member_number_policy(mode="automatic", pattern="MA-{year}-{number:03d}") + automatic = repository.create_member(first_name="Auto", last_name="Member") + assert automatic.member_number.startswith("MA-") + assert automatic.member_number.endswith("-001") + + repository.save_member_number_policy(mode="manual", pattern="MA-{number:03d}") + with pytest.raises(RepositoryError, match="erforderlich"): + repository.create_member(first_name="Missing", last_name="Number") + manual = repository.create_member(first_name="Manual", last_name="Member", member_number="SPECIAL-7") + assert manual.member_number == "SPECIAL-7" + with pytest.raises(RepositoryError, match="bereits vergeben"): + repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7") + + +@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"]) +def test_invalid_member_number_patterns_are_rejected(pattern) -> None: + with pytest.raises(RepositoryError): + validate_member_number_pattern(pattern) + + +def test_member_number_formatter_supports_padding_and_year() -> None: + assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042" diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py new file mode 100644 index 0000000..e08d5f8 --- /dev/null +++ b/tests/test_ui_imports.py @@ -0,0 +1,40 @@ +def test_ui_modules_import_without_creating_root_window() -> None: + import ccma.app # noqa: F401 + import ccma.ui.main_window # noqa: F401 + import ccma.ui.member_tab # noqa: F401 + import ccma.ui.splash # noqa: F401 + + +def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None: + from ccma.ui.splash import centered_position + + assert centered_position( + width=620, + height=330, + pointer_x=2500, + pointer_y=600, + screen_x=0, + screen_y=0, + screen_width=3840, + screen_height=1080, + ) == (2190, 435) + assert centered_position( + width=620, + height=330, + pointer_x=10, + pointer_y=10, + screen_x=0, + screen_y=0, + screen_width=1920, + screen_height=1080, + ) == (0, 0) + + +def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None: + from ccma.domain.models import Event + from ccma.ui.member_tab import _event_label + + user_event = Event("1", "2026-01-01T00:00:00+01:00", "comment", "Kommentar", "user", "Vorstand") + system_event = Event("2", "2026-01-01T00:00:00+01:00", "automatic", "Automatisch") + assert _event_label(user_event) == "Kommentar" + assert _event_label(system_event) == "[AUTO] Automatisch" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..f7f28e7 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,8 @@ +from pathlib import Path + +from ccma import __version__ + + +def test_ui_version_matches_version_file() -> None: + expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() + assert __version__ == expected == "0.0.1-dev0"