From 37c96428773d1832c879b8fa9c4ad89eb30debf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Mon, 7 Sep 2026 18:28:28 +0200 Subject: [PATCH] Initial commit: Arbeitsorte-Logger + data-safety/robustness/input work Existing app (single-file Tkinter/ttkbootstrap desktop tool for logging blood-drive work assignments and mapping them) plus the first round of improvements: - Data safety: atomic CSV writes (tmp + fsync + os.replace), rotating backups in data/backups/ (startup + before every change, keep 20), fallback to empty/backup on missing/empty/corrupt orte.csv. - Geocoder robustness: per-item try/except so the worker thread survives failures; GeocodingUnavailable + one-time "service unreachable" dialog. - Input: DateEntry calendar picker with parse_date() validation; manual lat/lon fields in the edit dialog; Tab 2 highlights/filters rows without coordinates and adds a right-click "Koordinaten suchen". - Logging to data/app.log; shared autocomplete helpers; config constants; map fit_bounds. docs/ describes current state, architecture, data model, setup (Linux Mint), and the full improvement roadmap. data/orte.csv is gitignored for now. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 22 + app.py | 1131 ++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 34 ++ docs/architecture.md | 89 ++++ docs/changelog.md | 47 ++ docs/data-model.md | 61 +++ docs/dev-notes.md | 35 ++ docs/improvements.md | 196 ++++++++ docs/overview.md | 85 ++++ docs/setup.md | 105 ++++ requirements.txt | 4 + 11 files changed, 1809 insertions(+) create mode 100644 .gitignore create mode 100755 app.py create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/changelog.md create mode 100644 docs/data-model.md create mode 100644 docs/dev-notes.md create mode 100644 docs/improvements.md create mode 100644 docs/overview.md create mode 100644 docs/setup.md create mode 100755 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19ced60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Generated map (build artefact, regenerated on demand) +data/karte.html + +# Real data — kept out of version control for now (see docs/data-model.md) +data/orte.csv + +# Local backups and logs +data/backups/ +data/app.log +data/app.log.* + +# Editor / OS +.vscode/ +.idea/ +.DS_Store diff --git a/app.py b/app.py new file mode 100755 index 0000000..cc7ab3e --- /dev/null +++ b/app.py @@ -0,0 +1,1131 @@ +""" +DRK Blutspende – Arbeitsorte Logger +Protokolliert Arbeitseinsätze mit Datum und Ort (mit PLZ). +Visualisiert Einsatzorte auf einer interaktiven Karte. +""" + +from __future__ import annotations + +import logging +import math +import os +import re +import shutil +import threading +import time +import webbrowser +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import folium +import pandas as pd +import ttkbootstrap as ttk +from geopy.exc import GeocoderServiceError, GeocoderTimedOut +from geopy.geocoders import Nominatim +from ttkbootstrap.constants import * + +import tkinter as tk +from tkinter import messagebox + +# ── Pfade ──────────────────────────────────────────────────────────────────── + +BASE_DIR = Path(__file__).parent +DATA_DIR = BASE_DIR / "data" +CSV_PATH = DATA_DIR / "orte.csv" +MAP_PATH = DATA_DIR / "karte.html" +BACKUP_DIR = DATA_DIR / "backups" +LOG_PATH = DATA_DIR / "app.log" +CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"] + +#: Wie viele automatische Backups von orte.csv aufbewahrt werden. +MAX_BACKUPS = 20 + +#: Regionen, die bei der Geokodierung bevorzugt durchsucht werden (Reihenfolge zählt). +GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"] + +#: Anfangsansicht der Karte (Mitte + Zoom), falls keine Punkte vorhanden sind. +MAP_DEFAULT_CENTER = [49.0, 9.0] +MAP_DEFAULT_ZOOM = 8 + +log = logging.getLogger("arbeitsorte") + + +# ── Datum ──────────────────────────────────────────────────────────────────── + +#: Eingabeformate, die beim Parsen akzeptiert werden. Gespeichert wird immer ISO. +_DATE_INPUT_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%Y/%m/%d"] + + +def parse_date(text: str) -> str | None: + """Normalisiert eine Datumseingabe auf ``JJJJ-MM-TT``. + + Gibt ``None`` zurück, wenn der Text kein gültiges Datum ist. + """ + text = (text or "").strip() + if not text: + return None + for fmt in _DATE_INPUT_FORMATS: + try: + return datetime.strptime(text, fmt).date().isoformat() + except ValueError: + continue + return None + + +def _setup_logging() -> None: + DATA_DIR.mkdir(exist_ok=True) + handler = RotatingFileHandler( + LOG_PATH, maxBytes=512_000, backupCount=3, encoding="utf-8" + ) + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)-7s %(message)s") + ) + root = logging.getLogger("arbeitsorte") + root.setLevel(logging.INFO) + root.addHandler(handler) + + +def _files_equal(a: Path, b: Path) -> bool: + try: + return a.read_bytes() == b.read_bytes() + except OSError: + return False + + +# ── Autovervollständigung (von Eingabe- und Bearbeiten-Formular genutzt) ────── + +_CITY_PLZ_RE = re.compile(r"^(.+?)\s*\((\d{4,5})\)\s*$") + + +def filter_locations(typed: str, all_locs: list[str], limit: int = 30) -> list[str]: + typed = typed.strip().lower() + matches = [s for s in all_locs if typed in s.lower()] if typed else all_locs + return matches[:limit] + + +def split_city_plz(value: str) -> tuple[str, str] | None: + """Zerlegt ``"Stuttgart (70173)"`` in ``("Stuttgart", "70173")``.""" + m = _CITY_PLZ_RE.match(value) + if m: + return m.group(1).strip(), m.group(2).strip() + return None + + +def _is_blank(value) -> bool: + """True für leere / fehlende Zellwerte ("", NaN, "nan", None).""" + if value is None: + return True + try: + if pd.isna(value): + return True + except (TypeError, ValueError): + pass + return str(value).strip().lower() in ("", "nan") + + +def _coord_str(value) -> str: + """Formatiert einen gespeicherten Koordinatenwert für ein Eingabefeld.""" + num = parse_coord("" if value is None else str(value)) + return "" if num is None else f"{num:.5f}" + + +def parse_coord(text: str) -> float | None: + """Parst eine Koordinateneingabe (akzeptiert Komma als Dezimaltrennzeichen).""" + text = (text or "").strip().replace(",", ".") + if not text: + return None + try: + value = float(text) + except ValueError: + return None + return value if math.isfinite(value) else None + +# ── DataStore ───────────────────────────────────────────────────────────────── + + +class DataStore: + """Verwaltet die CSV-Datei und die bekannten Orte. + + Schreibvorgänge laufen immer über :meth:`_save_csv`: erst ein rotierendes + Backup, dann ein atomarer Schreibvorgang (Temp-Datei + ``os.replace``), damit + ein Absturz mitten im Speichern die Daten nicht beschädigt. + """ + + def __init__(self) -> None: + DATA_DIR.mkdir(exist_ok=True) + BACKUP_DIR.mkdir(exist_ok=True) + if not CSV_PATH.exists(): + self._write_atomic(pd.DataFrame(columns=CSV_COLUMNS)) + self._load() + self._make_backup() # Sicherung des Standes beim Programmstart + + def _load(self) -> None: + try: + self.df = pd.read_csv( + CSV_PATH, dtype={"postal_code": str, "lat": str, "lon": str} + ) + except (pd.errors.EmptyDataError, FileNotFoundError): + log.warning("orte.csv fehlt oder ist leer – starte mit leerer Tabelle.") + self.df = pd.DataFrame(columns=CSV_COLUMNS) + except Exception: + log.exception("orte.csv konnte nicht gelesen werden – nutze letztes Backup.") + self.df = self._load_from_newest_backup() + for col in CSV_COLUMNS: + if col not in self.df.columns: + self.df[col] = "" + self.df = self.df[CSV_COLUMNS] + self._refresh_known() + + def _load_from_newest_backup(self) -> pd.DataFrame: + backups = sorted(BACKUP_DIR.glob("orte-*.csv"), reverse=True) + for path in backups: + try: + return pd.read_csv( + path, dtype={"postal_code": str, "lat": str, "lon": str} + ) + except Exception: + continue + return pd.DataFrame(columns=CSV_COLUMNS) + + # ── Schreiben (atomar + Backup) ────────────────────────────────────────── + + @staticmethod + def _write_atomic(df: pd.DataFrame) -> None: + """Schreibt ``df`` atomar nach CSV_PATH (Temp-Datei + os.replace).""" + tmp = CSV_PATH.with_name(CSV_PATH.name + ".tmp") + df.to_csv(tmp, index=False) + with open(tmp, "rb") as fh: + os.fsync(fh.fileno()) + os.replace(tmp, CSV_PATH) + + def _make_backup(self) -> None: + """Kopiert die aktuelle CSV nach data/backups/ und hält MAX_BACKUPS Stück.""" + if not CSV_PATH.exists() or CSV_PATH.stat().st_size == 0: + return + backups = sorted(BACKUP_DIR.glob("orte-*.csv"), reverse=True) + if backups and _files_equal(CSV_PATH, backups[0]): + return # nichts geändert seit dem letzten Backup + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = BACKUP_DIR / f"orte-{stamp}.csv" + try: + shutil.copy2(CSV_PATH, dest) + except Exception: + log.exception("Backup konnte nicht erstellt werden.") + return + for old in sorted(BACKUP_DIR.glob("orte-*.csv"), reverse=True)[MAX_BACKUPS:]: + old.unlink(missing_ok=True) + + def _save_csv(self) -> None: + self._make_backup() + self._write_atomic(self.df) + self._refresh_known() + log.info("orte.csv gespeichert (%d Einträge).", len(self.df)) + + def _refresh_known(self) -> None: + pairs = ( + self.df[["city", "postal_code"]] + .dropna(subset=["city"]) + .drop_duplicates() + ) + self._known: list[tuple[str, str]] = [ + (str(r.city).strip(), str(r.postal_code).strip() if pd.notna(r.postal_code) else "") + for _, r in pairs.iterrows() + if str(r.city).strip() + ] + + def get_autocomplete_strings(self) -> list[str]: + result = [] + for city, plz in self._known: + result.append(f"{city} ({plz})" if plz else city) + return sorted(set(result), key=str.lower) + + def append_rows(self, rows: list[dict]) -> None: + save_rows = [{k: v for k, v in r.items() if k in CSV_COLUMNS} for r in rows] + new_df = pd.DataFrame(save_rows, columns=CSV_COLUMNS) + self.df = pd.concat([self.df, new_df], ignore_index=True) + self._save_csv() + + def update_row(self, idx: int, values: dict) -> None: + """Aktualisiert einen bestehenden Eintrag und speichert die CSV.""" + for col, val in values.items(): + if col in CSV_COLUMNS: + # Columns are str-typed; convert floats/None to string + self.df.at[idx, col] = "" if (val is None or val == "") else str(val) + self._save_csv() + + def delete_rows(self, indices: list[int]) -> None: + """Löscht Einträge anhand ihrer DataFrame-Indizes.""" + self.df = self.df.drop(index=indices).reset_index(drop=True) + self._save_csv() + + def find_duplicates(self, rows: list[dict]) -> list[dict]: + dupes = [] + for row in rows: + mask = ( + (self.df["date"].astype(str) == str(row["date"])) + & (self.df["city"].astype(str).str.strip().str.lower() == str(row["city"]).strip().lower()) + & (self.df["postal_code"].astype(str).str.strip() == str(row["postal_code"]).strip()) + ) + if mask.any(): + dupes.append(row) + return dupes + + def total_count(self) -> int: + return len(self.df) + + def get_map_data(self) -> pd.DataFrame: + df = self.df.copy() + df["lat"] = pd.to_numeric(df["lat"], errors="coerce") + df["lon"] = pd.to_numeric(df["lon"], errors="coerce") + df = df.dropna(subset=["lat", "lon"]) + if df.empty: + return pd.DataFrame(columns=["city", "lat", "lon", "count"]) + return df.groupby(["city", "lat", "lon"]).size().reset_index(name="count") + + +# ── Geocodierung ────────────────────────────────────────────────────────────── + +_geolocator = Nominatim(user_agent="drk_blutspende_orte/1.0", timeout=10) + + +class GeocodingUnavailable(Exception): + """Alle Geokodier-Anfragen sind an einem Dienst-/Netzwerkfehler gescheitert.""" + + +def geocode_city(city: str, postal_code: str) -> tuple[float, float] | None: + """Sucht Koordinaten für ``city``/``postal_code``. + + Rückgabe ``None`` bedeutet "Ort nicht gefunden". Ein + :class:`GeocodingUnavailable` wird geworfen, wenn *jede* Anfrage an einem + Dienst- oder Netzwerkfehler gescheitert ist (z. B. keine Internetverbindung). + """ + queries: list[str] = [] + if postal_code: + queries.append(f"{postal_code} {city}, Germany") + queries += [f"{city}, {region}, Germany" for region in GEOCODE_REGIONS] + queries.append(f"{city}, Germany") + + had_result = False + for q in queries: + try: + result = _geolocator.geocode(q) + had_result = True + if result: + return result.latitude, result.longitude + except (GeocoderTimedOut, GeocoderServiceError) as exc: + log.warning("Geokodierung fehlgeschlagen für %r: %s", q, exc) + continue + except Exception: + log.exception("Unerwarteter Fehler bei der Geokodierung von %r", q) + continue + if not had_result: + raise GeocodingUnavailable(city) + return None + + +class GeocoderWorker(threading.Thread): + """Hintergrund-Thread für Geokodierung (Nominatim-Rate-Limit: 1 req/s). + + Der Thread bleibt auch dann am Leben, wenn eine einzelne Anfrage oder ein + Callback eine Exception wirft – sonst würden alle folgenden Einträge + dauerhaft in "wird gesucht…" hängen bleiben. + """ + + #: Ab so vielen Fehlschlägen in Folge wird ``on_service_error`` ausgelöst. + SERVICE_ERROR_THRESHOLD = 3 + + def __init__(self, on_service_error=None) -> None: + super().__init__(daemon=True) + self._items: list[tuple[str, str, str, object]] = [] + self._lock = threading.Lock() + self._event = threading.Event() + self._stopped = False + self._on_service_error = on_service_error + self._consecutive_errors = 0 + + def enqueue(self, row_id: str, city: str, plz: str, callback) -> None: + with self._lock: + self._items.append((row_id, city, plz, callback)) + self._event.set() + + def run(self) -> None: + while not self._stopped: + self._event.wait() + self._event.clear() + while not self._stopped: + with self._lock: + if not self._items: + break + row_id, city, plz, callback = self._items.pop(0) + + coords: tuple[float, float] | None = None + try: + coords = geocode_city(city, plz) + self._consecutive_errors = 0 + except GeocodingUnavailable: + self._consecutive_errors += 1 + log.warning( + "Standortdienst nicht erreichbar (%d in Folge) für %r.", + self._consecutive_errors, city, + ) + self._maybe_report_service_error() + except Exception: + log.exception("Geokodierung von %r abgebrochen.", city) + + try: + callback(row_id, coords) + except Exception: + log.exception("Geocode-Callback für %r fehlgeschlagen.", row_id) + + time.sleep(1) + + def _maybe_report_service_error(self) -> None: + if ( + self._on_service_error is not None + and self._consecutive_errors == self.SERVICE_ERROR_THRESHOLD + ): + try: + self._on_service_error() + except Exception: + log.exception("on_service_error-Callback fehlgeschlagen.") + + def stop(self) -> None: + self._stopped = True + self._event.set() + + +# ── Kartenerstellung ────────────────────────────────────────────────────────── + + +def generate_map(store: DataStore) -> Path: + freq = store.get_map_data() + if freq.empty: + raise ValueError("Keine Koordinaten vorhanden. Bitte zuerst Einträge speichern.") + + m = folium.Map( + location=MAP_DEFAULT_CENTER, zoom_start=MAP_DEFAULT_ZOOM, + tiles="CartoDB positron", + ) + max_count = int(freq["count"].max()) + + for _, row in freq.iterrows(): + count = int(row["count"]) + radius = 5 + (count / max_count) * 20 + folium.CircleMarker( + location=[float(row["lat"]), float(row["lon"])], + radius=radius, + popup=folium.Popup( + f"{row['city']}
{count} Einsatz/Einsätze", max_width=200 + ), + tooltip=str(row["city"]), + color="#CC0000", + fill=True, + fill_color="#CC0000", + fill_opacity=0.65, + weight=2, + ).add_to(m) + + if len(freq) > 1: + m.fit_bounds([ + [freq["lat"].min(), freq["lon"].min()], + [freq["lat"].max(), freq["lon"].max()], + ]) + + m.save(str(MAP_PATH)) + log.info("Karte erzeugt mit %d Orten.", len(freq)) + return MAP_PATH + + +# ── Bearbeiten-Dialog ───────────────────────────────────────────────────────── + + +class EditDialog(tk.Toplevel): + """Modal-Dialog zum Bearbeiten eines bestehenden Eintrags.""" + + def __init__(self, parent: App, df_idx: int, row: pd.Series) -> None: + super().__init__(parent) + self.parent = parent + self.df_idx = df_idx + self.result: dict | None = None + + self.title("Eintrag bearbeiten") + self.resizable(False, False) + self.grab_set() # modal + + pad = {"padx": 8, "pady": 4} + + ttk.Label(self, text="Datum:").grid(row=0, column=0, sticky=W, **pad) + start = parse_date(str(row.get("date", ""))) + start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None + self._date_entry = ttk.DateEntry( + self, dateformat="%Y-%m-%d", width=12, startdate=start_dt + ) + self._date_entry.grid(row=0, column=1, sticky=W, **pad) + if start: + self._date_entry.entry.delete(0, END) + self._date_entry.entry.insert(0, start) + + ttk.Label(self, text="Ort:").grid(row=1, column=0, sticky=W, **pad) + self._city_var = tk.StringVar(value=str(row.get("city", ""))) + self._city_cb = ttk.Combobox(self, textvariable=self._city_var, width=24) + self._city_cb["values"] = parent.store.get_autocomplete_strings() + self._city_cb.grid(row=1, column=1, sticky=W, **pad) + self._city_cb.bind("", self._on_city_key) + self._city_cb.bind("<>", self._on_city_selected) + + ttk.Label(self, text="PLZ:").grid(row=2, column=0, sticky=W, **pad) + self._plz_var = tk.StringVar(value=str(row.get("postal_code", ""))) + ttk.Entry(self, textvariable=self._plz_var, width=10).grid(row=2, column=1, sticky=W, **pad) + + self._regeocode_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + self, text="Koordinaten automatisch neu suchen", variable=self._regeocode_var, + bootstyle="round-toggle", command=self._on_regeocode_toggle, + ).grid(row=3, column=0, columnspan=2, sticky=W, **pad) + + coord_frame = ttk.LabelFrame(self, text=" Koordinaten (manuell) ", padding=(8, 4)) + coord_frame.grid(row=4, column=0, columnspan=2, sticky=EW, padx=8, pady=(4, 4)) + ttk.Label(coord_frame, text="Breitengrad:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=2) + self._lat_var = tk.StringVar(value=_coord_str(row.get("lat", ""))) + self._lat_entry = ttk.Entry(coord_frame, textvariable=self._lat_var, width=14) + self._lat_entry.grid(row=0, column=1, sticky=W, pady=2) + ttk.Label(coord_frame, text="Längengrad:").grid(row=1, column=0, sticky=W, padx=(0, 4), pady=2) + self._lon_var = tk.StringVar(value=_coord_str(row.get("lon", ""))) + self._lon_entry = ttk.Entry(coord_frame, textvariable=self._lon_var, width=14) + self._lon_entry.grid(row=1, column=1, sticky=W, pady=2) + ttk.Label( + coord_frame, text="Leer lassen = kein Kartenpunkt", bootstyle="secondary", + ).grid(row=2, column=0, columnspan=2, sticky=W, pady=(2, 0)) + + btn_frame = ttk.Frame(self) + btn_frame.grid(row=5, column=0, columnspan=2, pady=(8, 6)) + ttk.Button(btn_frame, text="Speichern", bootstyle="success", + command=self._on_save).pack(side=LEFT, padx=6) + ttk.Button(btn_frame, text="Abbrechen", bootstyle="secondary-outline", + command=self.destroy).pack(side=LEFT, padx=6) + + self.bind("", lambda _: self._on_save()) + self.bind("", lambda _: self.destroy()) + + # Center over parent + self.update_idletasks() + px, py = parent.winfo_x(), parent.winfo_y() + pw, ph = parent.winfo_width(), parent.winfo_height() + w, h = self.winfo_width(), self.winfo_height() + self.geometry(f"+{px + (pw - w) // 2}+{py + (ph - h) // 2}") + + def _on_regeocode_toggle(self) -> None: + state = DISABLED if self._regeocode_var.get() else NORMAL + self._lat_entry.configure(state=state) + self._lon_entry.configure(state=state) + + def _on_city_key(self, _event) -> None: + self._city_cb["values"] = filter_locations( + self._city_var.get(), self.parent.store.get_autocomplete_strings() + ) + + def _on_city_selected(self, _event) -> None: + parts = split_city_plz(self._city_var.get()) + if parts: + self._city_var.set(parts[0]) + self._plz_var.set(parts[1]) + + def _on_save(self) -> None: + iso = parse_date(self._date_entry.entry.get()) + if iso is None: + messagebox.showwarning( + "Ungültiges Datum", + "Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben.", + parent=self, + ) + return + + city = self._city_var.get().strip() + if not city: + messagebox.showwarning("Ort fehlt", "Bitte einen Ort eingeben.", parent=self) + return + + regeocode = self._regeocode_var.get() + lat = lon = "" + if not regeocode: + lat_val = parse_coord(self._lat_var.get()) + lon_val = parse_coord(self._lon_var.get()) + if (self._lat_var.get().strip() or self._lon_var.get().strip()) and ( + lat_val is None or lon_val is None + ): + messagebox.showwarning( + "Ungültige Koordinaten", + "Breitengrad und Längengrad müssen Zahlen sein – " + "oder beide leer bleiben.", + parent=self, + ) + return + lat = "" if lat_val is None else f"{lat_val:.5f}" + lon = "" if lon_val is None else f"{lon_val:.5f}" + + self.result = { + "date": iso, + "city": city, + "postal_code": self._plz_var.get().strip(), + "regeocode": regeocode, + "lat": lat, + "lon": lon, + } + self.destroy() + + +# ── Haupt-App ───────────────────────────────────────────────────────────────── + + +class App(ttk.Window): + def __init__(self) -> None: + super().__init__(themename="litera") + self.title("DRK Blutspende – Arbeitsorte") + self.minsize(760, 560) + + self.store = DataStore() + self.geocoder = GeocoderWorker( + on_service_error=lambda: self.after(0, self._on_geocode_service_error) + ) + self.geocoder.start() + self._service_error_shown = False + + self._queue: list[dict] = [] + self._next_id = 0 + + self._build_ui() + self._set_status(f"Bereit. Gesamt: {self.store.total_count()} Einträge.") + self.protocol("WM_DELETE_WINDOW", self._on_close) + log.info("App gestartet (%d Einträge).", self.store.total_count()) + + def _on_geocode_service_error(self) -> None: + """Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist.""" + self._set_status( + "Standortdienst nicht erreichbar – Koordinaten werden übersprungen. " + "Bitte Internetverbindung prüfen." + ) + if not self._service_error_shown: + self._service_error_shown = True + messagebox.showwarning( + "Keine Verbindung zum Standortdienst", + "Die Koordinatensuche (OpenStreetMap) ist gerade nicht erreichbar.\n\n" + "Einträge werden trotzdem gespeichert – nur ohne Kartenpunkt. " + "Die Koordinaten lassen sich später über \"Bearbeiten\" nachtragen.", + parent=self, + ) + + # ── UI-Aufbau ───────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + # Statusleiste (ganz unten, vor Notebook packen) + self._status_var = ttk.StringVar() + ttk.Label( + self, textvariable=self._status_var, bootstyle="secondary", + anchor=W, padding=(12, 4), + ).pack(fill=X, side=BOTTOM) + + # Notebook mit zwei Tabs + self.notebook = ttk.Notebook(self) + self.notebook.pack(fill=BOTH, expand=True, padx=8, pady=8) + + tab1 = ttk.Frame(self.notebook, padding=4) + tab2 = ttk.Frame(self.notebook, padding=4) + self.notebook.add(tab1, text=" Neuer Eintrag ") + self.notebook.add(tab2, text=" Einträge verwalten ") + + self._build_entry_tab(tab1) + self._build_history_tab(tab2) + + # Beim Wechsel zu Tab 2: Tabelle aktualisieren + self.notebook.bind("<>", self._on_tab_changed) + + # ── Tab 1: Neuer Eintrag ────────────────────────────────────────────────── + + def _build_entry_tab(self, parent) -> None: + input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10) + input_frame.pack(fill=X, padx=4, pady=(4, 6)) + + ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4)) + self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12) + self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 14)) + ttk.Label(input_frame, text="(JJJJ-MM-TT)", bootstyle="secondary").grid( + row=1, column=1, sticky=W + ) + + ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4)) + self._city_var = ttk.StringVar() + self.city_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24) + self.city_cb["values"] = self.store.get_autocomplete_strings() + self.city_cb.grid(row=0, column=3, sticky=W, padx=(0, 14)) + self.city_cb.bind("", self._on_city_key) + self.city_cb.bind("<>", self._on_city_selected) + + ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4)) + self._plz_var = ttk.StringVar() + plz_entry = ttk.Entry(input_frame, textvariable=self._plz_var, width=8) + plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 14)) + + add_btn = ttk.Button( + input_frame, text="+ Hinzufügen", bootstyle="success-outline", + command=self._on_add_row, + ) + add_btn.grid(row=0, column=6) + + for w in (self._date_entry.entry, self.city_cb, plz_entry): + w.bind("", lambda _e: self._on_add_row()) + + # Warteschlange + queue_frame = ttk.LabelFrame(parent, text=" Warteschlange (noch nicht gespeichert) ", padding=(8, 4)) + queue_frame.pack(fill=BOTH, expand=True, padx=4, pady=4) + + cols = ("date", "city", "postal_code", "coords", "del") + self.tree = ttk.Treeview(queue_frame, columns=cols, show="headings", height=9) + self.tree.heading("date", text="Datum") + self.tree.heading("city", text="Ort") + self.tree.heading("postal_code", text="PLZ") + self.tree.heading("coords", text="Koordinaten") + self.tree.heading("del", text="") + self.tree.column("date", width=100, minwidth=90, stretch=False) + self.tree.column("city", width=180, minwidth=120) + self.tree.column("postal_code", width=65, minwidth=50, stretch=False) + self.tree.column("coords", width=185, minwidth=130) + self.tree.column("del", width=32, minwidth=32, stretch=False, anchor=CENTER) + + sb = ttk.Scrollbar(queue_frame, orient=VERTICAL, command=self.tree.yview) + self.tree.configure(yscrollcommand=sb.set) + self.tree.pack(side=LEFT, fill=BOTH, expand=True) + sb.pack(side=RIGHT, fill=Y) + + self.tree.bind("", self._on_tree_click) + self.tree.bind("", self._on_queue_right_click) + + # Aktionsleiste + action_frame = ttk.Frame(parent, padding=(4, 4)) + action_frame.pack(fill=X) + ttk.Button(action_frame, text="Alle speichern", bootstyle="success", + command=self._on_save_all).pack(side=LEFT, padx=(0, 6)) + ttk.Button(action_frame, text="Leeren", bootstyle="secondary-outline", + command=self._on_clear_queue).pack(side=LEFT, padx=(0, 6)) + ttk.Button(action_frame, text="Karte öffnen", bootstyle="info", + command=self._on_open_map).pack(side=RIGHT) + + # ── Tab 2: Einträge verwalten ───────────────────────────────────────────── + + def _build_history_tab(self, parent) -> None: + # Suchleiste + search_frame = ttk.Frame(parent, padding=(4, 4, 4, 2)) + search_frame.pack(fill=X) + ttk.Label(search_frame, text="Suche:").pack(side=LEFT, padx=(0, 6)) + self._search_var = ttk.StringVar() + self._search_var.trace_add("write", lambda *_: self._refresh_history()) + ttk.Entry(search_frame, textvariable=self._search_var, width=30).pack(side=LEFT) + ttk.Button(search_frame, text="✕", bootstyle="secondary-link", width=3, + command=lambda: self._search_var.set("")).pack(side=LEFT) + ttk.Button(search_frame, text="Karte öffnen", bootstyle="info", + command=self._on_open_map).pack(side=RIGHT) + self._only_missing_var = ttk.BooleanVar(value=False) + ttk.Checkbutton( + search_frame, text="Nur ohne Koordinaten", + variable=self._only_missing_var, bootstyle="round-toggle", + command=self._refresh_history, + ).pack(side=LEFT, padx=(12, 0)) + + # Tabelle + hist_frame = ttk.Frame(parent, padding=(4, 2)) + hist_frame.pack(fill=BOTH, expand=True) + + cols = ("date", "city", "postal_code", "lat", "lon") + self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16) + self.hist_tree.heading("date", text="Datum", + command=lambda: self._sort_history("date")) + self.hist_tree.heading("city", text="Ort", + command=lambda: self._sort_history("city")) + self.hist_tree.heading("postal_code", text="PLZ", + command=lambda: self._sort_history("postal_code")) + self.hist_tree.heading("lat", text="Breitengrad", + command=lambda: self._sort_history("lat")) + self.hist_tree.heading("lon", text="Längengrad", + command=lambda: self._sort_history("lon")) + self.hist_tree.column("date", width=105, minwidth=90, stretch=False) + self.hist_tree.column("city", width=200, minwidth=120) + self.hist_tree.column("postal_code", width=65, minwidth=50, stretch=False) + self.hist_tree.column("lat", width=110, minwidth=80, stretch=False) + self.hist_tree.column("lon", width=110, minwidth=80, stretch=False) + + sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview) + self.hist_tree.configure(yscrollcommand=sb2.set) + self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True) + sb2.pack(side=RIGHT, fill=Y) + + self.hist_tree.tag_configure("missing", foreground="#CC0000") + + self.hist_tree.bind("", self._on_hist_double_click) + self.hist_tree.bind("", self._on_hist_right_click) + + self._sort_col = "date" + self._sort_asc = False # neueste zuerst + + # Aktionsleiste + action_frame = ttk.Frame(parent, padding=(4, 4)) + action_frame.pack(fill=X) + ttk.Button(action_frame, text="Bearbeiten", bootstyle="primary-outline", + command=self._on_hist_edit).pack(side=LEFT, padx=(0, 6)) + ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline", + command=self._on_hist_delete).pack(side=LEFT) + self._hist_hint_var = ttk.StringVar(value="(Doppelklick zum Bearbeiten)") + ttk.Label(action_frame, textvariable=self._hist_hint_var, + bootstyle="secondary").pack(side=RIGHT) + + def _on_tab_changed(self, _event) -> None: + if self.notebook.index("current") == 1: + self._refresh_history() + + def _refresh_history(self) -> None: + """Füllt die Verlaufstabelle neu aus dem DataStore.""" + for item in self.hist_tree.get_children(): + self.hist_tree.delete(item) + + df = self.store.df.copy() + missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank) + total_missing = int(missing.sum()) + + query = self._search_var.get().strip().lower() + if query: + mask = ( + df["date"].astype(str).str.lower().str.contains(query) | + df["city"].astype(str).str.lower().str.contains(query) | + df["postal_code"].astype(str).str.lower().str.contains(query) + ) + df = df[mask] + if self._only_missing_var.get(): + df = df[missing.reindex(df.index, fill_value=False)] + + df = df.sort_values(self._sort_col, ascending=self._sort_asc, na_position="last") + + for idx, row in df.iterrows(): + row_missing = _is_blank(row.get("lat", "")) or _is_blank(row.get("lon", "")) + self.hist_tree.insert( + "", END, iid=str(idx), + tags=("missing",) if row_missing else (), + values=( + row.get("date", ""), + row.get("city", ""), + row.get("postal_code", ""), + row.get("lat", ""), + row.get("lon", ""), + ), + ) + + if total_missing: + self._hist_hint_var.set( + f"{total_missing} Eintrag/Einträge ohne Koordinaten (rot) – " + f"Rechtsklick › Koordinaten suchen" + ) + else: + self._hist_hint_var.set("(Doppelklick zum Bearbeiten)") + + def _sort_history(self, col: str) -> None: + if self._sort_col == col: + self._sort_asc = not self._sort_asc + else: + self._sort_col = col + self._sort_asc = True + self._refresh_history() + + def _on_hist_double_click(self, event) -> None: + if self.hist_tree.identify_region(event.x, event.y) == "cell": + self._on_hist_edit() + + def _on_hist_right_click(self, event) -> None: + iid = self.hist_tree.identify_row(event.y) + if not iid: + return + self.hist_tree.selection_set(iid) + menu = tk.Menu(self, tearoff=0) + menu.add_command(label="Bearbeiten", command=self._on_hist_edit) + menu.add_command(label="Koordinaten suchen", command=self._on_hist_geocode) + menu.add_separator() + menu.add_command(label="Löschen", command=self._on_hist_delete) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _on_hist_geocode(self) -> None: + sel = self.hist_tree.selection() + if not sel: + self._set_status("Bitte einen Eintrag auswählen.") + return + df_idx = int(sel[0]) + row = self.store.df.loc[df_idx] + city = str(row.get("city", "")).strip() + plz = str(row.get("postal_code", "")).strip() + if not city: + self._set_status("Eintrag hat keinen Ort – Koordinatensuche nicht möglich.") + return + self.geocoder.enqueue( + f"edit_{df_idx}", city, plz, + lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords), + ) + self._set_status(f"Koordinaten werden gesucht für {city}…") + + def _on_hist_edit(self) -> None: + sel = self.hist_tree.selection() + if not sel: + self._set_status("Bitte einen Eintrag auswählen.") + return + df_idx = int(sel[0]) + row = self.store.df.loc[df_idx] + + dlg = EditDialog(self, df_idx, row) + self.wait_window(dlg) + + if dlg.result is None: + return # Abgebrochen + + new_vals = { + "date": dlg.result["date"], + "city": dlg.result["city"], + "postal_code": dlg.result["postal_code"], + } + + if dlg.result["regeocode"]: + # Zuerst speichern (ohne Koordinaten), dann im Hintergrund geocodieren + new_vals["lat"] = "" + new_vals["lon"] = "" + self.store.update_row(df_idx, new_vals) + self.geocoder.enqueue( + f"edit_{df_idx}", + dlg.result["city"], + dlg.result["postal_code"], + lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords), + ) + self._set_status(f"Koordinaten werden gesucht für {new_vals['city']}…") + else: + new_vals["lat"] = dlg.result["lat"] + new_vals["lon"] = dlg.result["lon"] + self.store.update_row(df_idx, new_vals) + self._set_status( + f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}. " + f"Gesamt: {self.store.total_count()} Einträge." + ) + + self.city_cb["values"] = self.store.get_autocomplete_strings() + self._refresh_history() + + def _apply_edit_geocode(self, df_idx: int, coords: tuple | None) -> None: + """Koordinaten nach Hintergrund-Geocodierung in bestehenden Eintrag schreiben.""" + if df_idx not in self.store.df.index: + return # Zeile wurde zwischenzeitlich gelöscht + if coords: + self.store.update_row(df_idx, { + "lat": round(coords[0], 5), + "lon": round(coords[1], 5), + }) + self._set_status( + f"Koordinaten aktualisiert. Gesamt: {self.store.total_count()} Einträge." + ) + else: + self._set_status("Koordinaten konnten nicht gefunden werden.") + self._refresh_history() + + def _on_hist_delete(self) -> None: + sel = self.hist_tree.selection() + if not sel: + self._set_status("Bitte mindestens einen Eintrag auswählen.") + return + count = len(sel) + label = f"{count} Eintrag/Einträge" if count > 1 else "diesen Eintrag" + if not messagebox.askyesno( + "Löschen bestätigen", + f"Soll {label} wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.", + parent=self, + ): + return + indices = [int(iid) for iid in sel] + self.store.delete_rows(indices) + self._refresh_history() + self._set_status( + f"{count} Eintrag/Einträge gelöscht. Gesamt: {self.store.total_count()} Einträge." + ) + + # ── Autovervollständigung ───────────────────────────────────────────────── + + def _on_city_key(self, _event) -> None: + self.city_cb["values"] = filter_locations( + self._city_var.get(), self.store.get_autocomplete_strings() + ) + + def _on_city_selected(self, _event) -> None: + parts = split_city_plz(self._city_var.get()) + if parts: + self._city_var.set(parts[0]) + self._plz_var.set(parts[1]) + + # ── Warteschlange ───────────────────────────────────────────────────────── + + def _on_add_row(self) -> None: + city = self._city_var.get().strip() + plz = self._plz_var.get().strip() + date_str = parse_date(self._date_entry.entry.get()) + + if not city: + self._set_status("Bitte einen Ort eingeben.") + return + if date_str is None: + self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.") + messagebox.showwarning( + "Ungültiges Datum", + "Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben " + "oder den Kalender-Knopf benutzen.", + parent=self, + ) + return + + row_id = str(self._next_id) + self._next_id += 1 + row = {"_id": row_id, "date": date_str, "city": city, + "postal_code": plz, "lat": "", "lon": ""} + self._queue.append(row) + + self.tree.insert("", END, iid=row_id, + values=(date_str, city, plz, "⏳ wird gesucht…", "✕")) + self.geocoder.enqueue(row_id, city, plz, self._geocode_done) + + self._city_var.set("") + self._plz_var.set("") + self.city_cb.focus_set() + self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.") + + def _geocode_done(self, row_id: str, coords: tuple | None) -> None: + self.after(0, self._apply_geocode_result, row_id, coords) + + def _apply_geocode_result(self, row_id: str, coords: tuple | None) -> None: + for row in self._queue: + if row["_id"] == row_id: + if coords: + row["lat"] = round(coords[0], 5) + row["lon"] = round(coords[1], 5) + coord_str = f"{row['lat']:.4f} / {row['lon']:.4f}" + else: + coord_str = "⚠ nicht gefunden" + break + else: + return + + if self.tree.exists(row_id): + vals = list(self.tree.item(row_id, "values")) + vals[3] = coord_str + self.tree.item(row_id, values=vals) + + def _on_tree_click(self, event) -> None: + if self.tree.identify_region(event.x, event.y) != "cell": + return + if self.tree.identify_column(event.x) == "#5": + row_id = self.tree.identify_row(event.y) + if row_id: + self._delete_queue_row(row_id) + + def _on_queue_right_click(self, event) -> None: + row_id = self.tree.identify_row(event.y) + if not row_id: + return + menu = tk.Menu(self, tearoff=0) + menu.add_command(label="Löschen", command=lambda: self._delete_queue_row(row_id)) + menu.add_command(label="Koordinaten erneut suchen", + command=lambda: self._retry_geocode(row_id)) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _delete_queue_row(self, row_id: str) -> None: + self._queue = [r for r in self._queue if r["_id"] != row_id] + if self.tree.exists(row_id): + self.tree.delete(row_id) + self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.") + + def _retry_geocode(self, row_id: str) -> None: + for row in self._queue: + if row["_id"] == row_id: + if self.tree.exists(row_id): + vals = list(self.tree.item(row_id, "values")) + vals[3] = "⏳ wird gesucht…" + self.tree.item(row_id, values=vals) + self.geocoder.enqueue(row_id, row["city"], row["postal_code"], + self._geocode_done) + break + + def _on_clear_queue(self) -> None: + self._queue.clear() + for item in self.tree.get_children(): + self.tree.delete(item) + self._set_status("Warteschlange geleert.") + + # ── Speichern ───────────────────────────────────────────────────────────── + + def _on_save_all(self) -> None: + if not self._queue: + self._set_status("Warteschlange ist leer.") + return + + dupes = self.store.find_duplicates(self._queue) + if dupes: + names = ", ".join(f"{d['date']} {d['city']}" for d in dupes[:3]) + suffix = " u.w." if len(dupes) > 3 else "" + self._set_status( + f"Hinweis: Mögliche Duplikate ({names}{suffix}). Trotzdem gespeichert." + ) + + self.store.append_rows(self._queue) + count = len(self._queue) + self._queue.clear() + for item in self.tree.get_children(): + self.tree.delete(item) + + self.city_cb["values"] = self.store.get_autocomplete_strings() + self._refresh_history() # Tab 2 sofort aktualisieren + self._set_status( + f"{count} Einträge gespeichert. Gesamt: {self.store.total_count()} Einträge." + ) + + # ── Karte ───────────────────────────────────────────────────────────────── + + def _on_open_map(self) -> None: + try: + path = generate_map(self.store) + except ValueError as e: + self._set_status(str(e)) + return + except Exception as e: + log.exception("Karte konnte nicht erstellt werden.") + self._set_status(f"Fehler beim Erstellen der Karte: {e}") + return + try: + webbrowser.open(path.as_uri()) + except Exception: + log.exception("Karte konnte nicht im Browser geöffnet werden.") + self._set_status(f"Karte geöffnet: {path}") + + # ── Hilfsmethoden ───────────────────────────────────────────────────────── + + def _set_status(self, msg: str) -> None: + self._status_var.set(msg) + + def _on_close(self) -> None: + log.info("App wird beendet.") + self.geocoder.stop() + self.destroy() + + +# ── Start ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + _setup_logging() + try: + app = App() + app.mainloop() + except Exception: + log.exception("Nicht abgefangener Fehler – App wird beendet.") + raise diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f8c13b0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# DRK Blutspende – Arbeitsorte: Dokumentation + +Developer/maintainer documentation for the **Arbeitsorte-Logger**, a small +desktop app that records blood-drive work assignments (date + city + postal +code) and plots them on an interactive map. + +> Context: built as a personal tool for a single user, running locally on a +> **Linux Mint laptop**. Ease of use for a non-technical user is the primary +> design constraint. There is no server, no multi-user story, and no network +> dependency beyond geocoding and the map tiles. + +## Documents + +| File | Contents | +|------|----------| +| [overview.md](overview.md) | What the app does, feature by feature | +| [architecture.md](architecture.md) | Code structure, threading model, data flow | +| [data-model.md](data-model.md) | The `orte.csv` schema and how it is read/written | +| [setup.md](setup.md) | Install & run on a fresh Linux Mint machine, plus a desktop launcher | +| [improvements.md](improvements.md) | Prioritized review findings and suggested changes | +| [changelog.md](changelog.md) | What has changed, newest first | +| [dev-notes.md](dev-notes.md) | Assumptions that turned out wrong — read before editing | + +## At a glance + +- **Language / stack:** Python 3, Tkinter via [`ttkbootstrap`](https://ttkbootstrap.readthedocs.io/) +- **Single file:** [`app.py`](../app.py) (~760 lines) +- **Storage:** one CSV file, `data/orte.csv` +- **Map:** generated on demand as `data/karte.html` with [Folium](https://python-visualization.github.io/folium/) (Leaflet), opened in the default browser +- **Geocoding:** OpenStreetMap Nominatim via [`geopy`](https://geopy.readthedocs.io/), rate-limited to 1 request/second on a background thread +- **UI language:** German +- **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log` +- **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite) +- **Version control:** git initialized 2026-09-07 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fffd22c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,89 @@ +# Architecture + +Everything lives in [`app.py`](../app.py). There is no package structure. + +## Components + +| Class / function | Responsibility | +|------------------|----------------| +| `DataStore` | Owns `orte.csv`. Loads it into a pandas `DataFrame`, exposes read helpers (`get_map_data`, `get_autocomplete_strings`, `total_count`, `find_duplicates`) and write helpers (`append_rows`, `update_row`, `delete_rows`). All writes go through `_save_csv` → rotating backup + atomic write. All columns are read as strings. Falls back to an empty frame (or the newest backup) if `orte.csv` is missing/empty/corrupt. | +| `geocode_city()` | Pure function: `(city, plz) -> (lat, lon) | None`. Tries several Nominatim queries with a configurable regional bias (`GEOCODE_REGIONS`). Raises `GeocodingUnavailable` if *every* query failed on a service/network error (vs. simply not finding the place). | +| `GeocoderWorker` | A `threading.Thread` (daemon). Holds a FIFO list of `(row_id, city, plz, callback)` items guarded by a lock, woken by an `Event`. Processes one item per second, calls `geocode_city`, then invokes `callback` **from the worker thread**. Each item is wrapped in `try/except` so one failure never kills the thread; after `SERVICE_ERROR_THRESHOLD` consecutive service errors it fires the optional `on_service_error` callback. | +| `parse_date()` / `parse_coord()` / helpers | Module-level pure functions for normalizing user input (date → ISO, coordinate strings → float, `"City (PLZ)"` splitting). Shared by the entry tab and `EditDialog`. | +| `generate_map()` | Builds `karte.html` from `DataStore.get_map_data()` with Folium. | +| `EditDialog` | `tk.Toplevel` modal dialog for editing one row. Returns its result via `self.result`. | +| `App` | `ttkbootstrap.Window`. Builds the UI, owns the `DataStore`, the `GeocoderWorker`, and the in-memory `_queue` list for Tab 1. | + +## Data flow + +### Adding entries +``` +user types -> _on_add_row() + -> append dict to self._queue + -> insert row into Tab-1 Treeview (coords = "⏳ wird gesucht…") + -> geocoder.enqueue(row_id, city, plz, self._geocode_done) + +GeocoderWorker thread (1/sec): + coords = geocode_city(...) + -> self._geocode_done(row_id, coords) [worker thread] + -> self.after(0, self._apply_geocode_result, ...) [hop to UI thread] + -> mutate self._queue entry + update Treeview cell + +user clicks "Alle speichern" -> _on_save_all() + -> DataStore.find_duplicates() (status-bar hint only) + -> DataStore.append_rows() -> CSV append + in-memory concat + -> clear queue, refresh Tab 2, update autocomplete +``` + +### Editing / deleting +`EditDialog` -> `DataStore.update_row(df_idx, values)` or +`DataStore.delete_rows(indices)` -> full `df.to_csv()` rewrite -> refresh. + +## Threading model + +- Tkinter is single-threaded; all widget access must happen on the main thread. +- The geocoder runs off-thread so the UI never blocks for the 1-second-per-item + rate limit. +- The **callback is invoked on the worker thread**, and every callback in + `App` immediately does `self.after(0, ...)` to marshal back onto the UI + thread. This is the load-bearing convention — any new callback must follow it. + (`on_service_error` does the same.) +- The worker catches every exception per item, so a bug in a callback or a + network error logs a traceback but never stops the queue. +- On window close, `_on_close()` calls `geocoder.stop()` (sets a flag + wakes + the event) and then `destroy()`. The thread is a daemon, so a missed stop + won't hang the process. + +## Persistence model + +- `orte.csv` is the single source of truth. It is read once at startup and then + kept in sync in memory. +- **Every** write (append, update, delete) rebuilds the full `DataFrame` and + goes through `DataStore._save_csv`: + 1. `_make_backup()` – copy the current file to + `data/backups/orte-YYYYMMDD-HHMMSS.csv` (skipped if byte-identical to the + newest backup); prune to `MAX_BACKUPS` (20). + 2. `_write_atomic()` – write to `orte.csv.tmp`, `fsync`, then `os.replace()` + onto `orte.csv` (atomic on POSIX). A crash mid-write leaves the old file + intact. +- A backup is also taken once at startup. +- Still **no file locking**: if the CSV is edited in another program while the + app is open, the next in-app save overwrites those changes (but the pre-write + backup captures them). + +## Logging + +`_setup_logging()` (called from `__main__`) attaches a `RotatingFileHandler` to +`data/app.log` (512 KB × 3). Logger name `arbeitsorte`, module-level `log`. +Records saves, map generation, geocoding failures, and any uncaught exception. +This is the file to ask for when diagnosing a problem on the user's laptop. + +## External dependencies at runtime + +| Dependency | Needed for | Offline behaviour | +|------------|-----------|-------------------| +| Nominatim (OSM) | geocoding new/edited entries | entries save with blank coords, shown as `⚠ nicht gefunden` | +| CDN (jsdelivr, jquery, cloudflare) | rendering `karte.html` | map page loads blank / unstyled | +| CartoDB tiles | map background | grey tiles | + +Existing entries that already have coordinates do **not** need the network. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..3470be2 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,47 @@ +# Changelog + +Notable changes to the app. Newest first. + +## 2026-09-07 — data safety, robustness, easier input + +Addressed the top three findings from [improvements.md](improvements.md). + +### Data safety (`DataStore`) +- All writes (append / update / delete) now rebuild the full DataFrame and go + through `_save_csv`: **rotating backup** to `data/backups/` (startup + before + every change, keep 20, skip if unchanged) then **atomic write** + (`tmp` + `fsync` + `os.replace`). +- `orte.csv` missing / empty (`EmptyDataError`) / unreadable now falls back to an + empty table or the newest backup instead of crashing. + +### Geocoder robustness (`GeocoderWorker`, `geocode_city`) +- Each queue item runs inside `try/except`; a failing lookup or callback logs a + traceback and the worker keeps going (previously an unexpected error killed the + thread and silently froze all further geocoding). +- `geocode_city` raises `GeocodingUnavailable` when every query failed on a + service/network error. After 3 in a row the app shows a one-time + "Standortdienst nicht erreichbar" dialog; entries still save without + coordinates. + +### Easier, safer input +- Date fields (entry form + edit dialog) are now `ttkbootstrap.DateEntry` + calendar pickers, validated/normalized to ISO by `parse_date()` (also accepts + `TT.MM.JJJJ`). Invalid dates are rejected with a dialog. +- Edit dialog has manual **Breitengrad / Längengrad** fields for fixing + coordinates by hand. +- Tab 2: rows without coordinates shown in red, "Nur ohne Koordinaten" filter, + count in the hint line, right-click **Koordinaten suchen**. + +### Housekeeping +- `git init` + `.gitignore`. +- Rotating log file at `data/app.log` (`_setup_logging`). +- Shared helpers `filter_locations` / `split_city_plz` / `parse_coord` replace + duplicated combobox code. +- Config constants: `GEOCODE_REGIONS`, `MAP_DEFAULT_CENTER`, `MAP_DEFAULT_ZOOM`, + `MAX_BACKUPS`. +- `generate_map` fits the view to the markers. + +### Verification +Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder +resilience) pass. **The GUI could not be run on the development Mac** — see +[dev-notes.md](dev-notes.md). diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..8466572 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,61 @@ +# Data model + +## `data/orte.csv` + +Plain CSV, comma-separated, UTF-8, with a header row. Created automatically with +just the header if it does not exist. + +| Column | Type on disk | Meaning | Notes | +|--------|--------------|---------|-------| +| `date` | string | Assignment date | Always stored as `YYYY-MM-DD`. Input is validated and normalized by `parse_date()` (accepts `YYYY-MM-DD`, `DD.MM.YYYY`, `DD.MM.YY`, `YYYY/MM/DD`); invalid input is rejected before saving. Rows written before this change may still hold non-ISO strings. | +| `city` | string | Place name | Free text. Used for autocomplete and duplicate detection (trimmed, case-insensitive). | +| `postal_code` | string | German PLZ | Optional. Kept as a string so leading zeros survive. Regex elsewhere accepts 4–5 digits. | +| `lat` | string | Latitude | Blank until geocoded. Rounded to 5 dp. Parsed with `pd.to_numeric(errors="coerce")` when building the map. | +| `lon` | string | Longitude | As above. | + +Every column is read as a string (`dtype={"postal_code": str, "lat": str, "lon": str}` +plus `city`/`date` default object). Numeric conversion happens only where needed. + +### Row identity + +There is **no ID column**. Rows are identified by their pandas `DataFrame` +index, which is reset to `0..n-1` after every delete. UI tables use that index +as the Treeview `iid`. + +Implication: a geocode callback that is in flight while the user deletes a +different row can land on the wrong row, because indices shift. `update_row` / +`_apply_edit_geocode` guard against a *missing* index but not against a +*reused* one. Still open — see [improvements.md](improvements.md#7-stable-row-identity-). +The manual lat/lon fields in the edit dialog give a way to correct any row +that ends up wrong. + +### Duplicate detection + +`find_duplicates` flags a queued row when an existing row matches on all three +of: `date` (string-equal), `city` (trimmed, lower-cased), `postal_code` +(trimmed). It only produces a status-bar hint; duplicates are still written. + +### Map aggregation + +`get_map_data` groups by `(city, lat, lon)` and counts rows. Two entries for the +same city with different coordinates (e.g. Karlsruhe geocoded once to `76133` +and once to `76185`) produce **two separate markers**. + +## `data/karte.html` + +Regenerated from scratch every time the user opens the map. Safe to delete; it +is a build artefact, not data. Should be git-ignored if the project is ever put +under version control. + +## `data/backups/` + +Automatic timestamped copies of `orte.csv`, named `orte-YYYYMMDD-HHMMSS.csv`. +One is written at startup and one before every change (skipped when nothing +changed since the last backup). The newest 20 are kept; older ones are pruned. +Git-ignored. To restore, copy a backup over `data/orte.csv` while the app is +closed. + +## `data/app.log` + +Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map +generation, geocoding failures, and uncaught exceptions. diff --git a/docs/dev-notes.md b/docs/dev-notes.md new file mode 100644 index 0000000..b21712f --- /dev/null +++ b/docs/dev-notes.md @@ -0,0 +1,35 @@ +# Dev notes — assumptions that turned out wrong + +Running list so the same mistakes aren't repeated. Add to it whenever reality +contradicts an assumption. + +## The GUI does not run on the development Mac (2026-09-07) + +The macOS **CommandLineTools** Python 3.9 (`/Library/Developer/CommandLineTools/ +.../python3.9`) ships **Tk 8.5.9**. `ttkbootstrap` 1.10.1 requires **Tk 8.6+**. + +Symptoms when trying to launch `App()` there: +- `_tkinter.TclError: couldn't recognize image data` (ttkbootstrap's window icon + is a PNG; Tk 8.5 can't decode it), and +- `_tkinter.TclError: unknown option "-style"` on `ttk.Scrollbar`. + +Consequences: +- `app.py` **cannot be smoke-tested through the GUI on this machine.** Logic is + covered by a non-GUI script (constructs `DataStore`, exercises helpers and the + worker with monkey-patched paths). +- The **Linux Mint target is unaffected** — its system Tk is 8.6. +- If GUI testing on macOS is ever needed, install a Homebrew + `python-tk@3.x` / `tcl-tk` combo, or use `pyenv` with `--with-tcltk`. + +## `pandas` reads everything as strings by design + +`DataStore._load` uses `dtype={...: str}` for `postal_code`, `lat`, `lon`. +Numeric parsing is deliberately deferred to `get_map_data` (`pd.to_numeric(..., +errors="coerce")`). Don't "fix" columns to float on load — blank coordinates and +leading-zero PLZ both depend on the string representation. + +## Nominatim query order matters + +`geocode_city` returns the **first** hit, trying PLZ-qualified first, then each +region in `GEOCODE_REGIONS`, then a bare `", Germany"`. Reordering changes which +coordinates ambiguous town names resolve to. diff --git a/docs/improvements.md b/docs/improvements.md new file mode 100644 index 0000000..e4b8c0e --- /dev/null +++ b/docs/improvements.md @@ -0,0 +1,196 @@ +# Improvement roadmap + +Review of the current code, prioritized for the actual deployment: **one +non-technical user, Linux Mint laptop, offline some of the time, no one else to +fix things when they break.** That ordering puts *data safety* and *robustness* +above features. + +Priorities: 🔴 do first · 🟡 worth doing · 🟢 nice to have + +**Status (2026-09-07):** items 1, 2 and 3 are implemented. Items 6 and 10 are +partly done (logging added; shared autocomplete helpers extracted; map now +`fit_bounds`; region list is now a constant). See +[changelog.md](changelog.md). + +--- + +## 1. Data safety 🔴 — ✅ done + +Implemented: atomic writes (`tmp` + `fsync` + `os.replace`), rotating backups in +`data/backups/` (startup + before every change, keep 20), `EmptyDataError` / +corrupt-file fallback to an empty frame or the newest backup, and all writes +(including append) routed through `_save_csv`. A "restore from backup" button in +the UI is still open; for now it is a manual file copy. + +
Original finding + +`data/orte.csv` is the only copy of the data and there is no safety net. + +- **No backups.** A bad edit, a botched delete, or a disk hiccup loses history + permanently. Delete is explicitly irreversible and only guarded by a yes/no + dialog. +- **Non-atomic full rewrites.** `update_row` / `delete_rows` do + `df.to_csv(CSV_PATH)` directly. A crash or power loss mid-write can leave a + truncated file. +- **External-edit clobber.** The CSV is read once at startup. If it is opened in + a spreadsheet and changed while the app runs, the next save silently + overwrites those changes. +- **Empty-file crash.** If `orte.csv` exists but is 0 bytes (e.g. an interrupted + write), `pd.read_csv` raises `EmptyDataError` and the app won't start. + +**Suggested changes** + +- Write every full rewrite to a temp file in the same directory, then + `os.replace()` it into place (atomic on POSIX). +- On startup, and before each destructive write, copy the current file to + `data/backups/orte-YYYYMMDD-HHMMSS.csv`; keep the last ~20 and prune the rest. +- Add a "Backup jetzt erstellen" / "Aus Backup wiederherstellen" pair in Tab 2, + or at least open the backups folder from a menu. +- Handle `EmptyDataError` / missing columns by falling back to an empty frame + and logging a warning. + +
+ +## 2. Geocoder thread robustness 🔴 — ✅ done + +Implemented: every queue item is wrapped in `try/except Exception` (both the +geocode call and the callback), failures are logged and the loop continues. +`geocode_city` now raises `GeocodingUnavailable` when every attempt hit a +service/network error, and after 3 consecutive such failures the app shows a +one-time "Standortdienst nicht erreichbar" dialog. `RateLimiter` was not +adopted; the manual `sleep(1)` stays. + +
Original finding + +`GeocoderWorker.run` has no error boundary around the per-item work. If +`geocode_city` or a callback raises anything other than +`GeocoderTimedOut` / `GeocoderServiceError` (a network stack error, a bug in a +callback, `GeocoderQuotaExceeded`), the **worker thread dies** and every +subsequent entry stays stuck on `⏳ wird gesucht…` with no error shown. The user +has no way to know geocoding stopped working. + +**Suggested changes** + +- Wrap each item's processing in `try/except Exception`, log the traceback, mark + that row as failed, and keep the loop alive. +- Consider `geopy.extra.rate_limiter.RateLimiter` (with `swallow_exceptions`) + instead of the manual `sleep(1)`. +- Surface a clear one-time status message when geocoding fails repeatedly + ("Standortdienst nicht erreichbar – bitte Internetverbindung prüfen"). + +
+ +## 3. Easier, safer input 🔴 — ✅ done + +Implemented: the date field is now `ttkbootstrap.DateEntry` (calendar picker) in +both the entry form and the edit dialog, with `parse_date()` validation on save +(a bad date is rejected with a dialog). The edit dialog gained manual +**Breitengrad / Längengrad** fields. Tab 2 shows rows without coordinates in red, +has a "Nur ohne Koordinaten" filter, a hint line with the count, and a +right-click **Koordinaten suchen** action. + +
Original finding + +For the target user the free-text date field is the biggest usability risk. + +- **Date field is unvalidated free text** in both the entry form and + `EditDialog`. A typo like `2026-13-05` or `05.02.2026` is stored verbatim, + breaks date sorting, and never appears correctly on the timeline. +- **No recourse when geocoding fails.** A row that comes back + `⚠ nicht gefunden` saves with blank coordinates and then just silently never + shows on the map. There is no way to type coordinates by hand or retry from + Tab 2. + +**Suggested changes** + +- Replace the date `Entry` with `ttkbootstrap.DateEntry` (calendar picker) in + both places. If keeping free text, validate on add/save and refuse invalid + dates with a clear message. +- In `EditDialog`, add optional `lat` / `lon` fields so a location can be fixed + manually. +- In Tab 2, highlight rows with missing coordinates (e.g. red text) and add a + filter / right-click "Koordinaten suchen" so failed geocodes are visible and + fixable after the fact. + +
+ +## 4. Put it under version control 🟡 — partly done + +`git init` done and `.gitignore` added (covers `.venv/`, `__pycache__/`, +`data/karte.html`, `data/backups/`, `data/app.log`). Still open: the initial +commit (left to Patrick), and the decision on committing `data/orte.csv` vs. an +example file. +- Decide on `data/orte.csv`: for a personal tool it's reasonable to commit it, + or commit a small `data/orte.example.csv` and ignore the real one. +- This `docs/` folder. + +Gives you history, a rollback path, and a clean way to push updates to the +laptop (`git pull`). + +## 5. Packaging & distribution 🟡 + +Today install is: `apt install python3-tk`, create a venv, `pip install`. That's +a one-time terminal session, which is acceptable but fragile (loose version +pins mean a future `pip install` could pull an incompatible pandas/folium). + +**Suggested changes** + +- Pin all four dependencies to exact versions and regenerate deliberately. +- Ship the `run.sh` + `.desktop` launcher from [setup.md](setup.md) in the repo. +- Optional: build a [PyInstaller](https://pyinstaller.org/) one-file bundle on a + matching Linux box. It bundles Python, Tk, and all deps, so install becomes + "copy one file + double-click" with no apt/venv step. Trade-off: you build it, + and rebuild on dependency updates. + +## 6. Diagnostics / logging 🟡 — ✅ done + +`_setup_logging()` writes a `RotatingFileHandler` to `data/app.log` +(512 KB × 3). Saves, map generation, geocode failures and uncaught exceptions +are logged. `_on_open_map` now logs and narrows its handling. Open: no in-app +"open log folder" shortcut yet. + +## 7. Stable row identity 🟡 + +Rows are keyed by the pandas index, which is renumbered after every delete. An +in-flight geocode callback for one row can land on another row if the user +deletes something in between. `_apply_edit_geocode` guards against a *deleted* +index but not a *reused* one. + +**Suggested change:** add an immutable `id` column (uuid or incrementing int) +written to the CSV, and match callbacks on that instead of the positional index. + +## 8. Duplicate handling 🟡 + +`_on_save_all` detects likely duplicates but saves them anyway with only a +transient status-bar line the user will probably miss. Consider a modal +"Diese Einträge existieren schon – trotzdem speichern?" with a per-row choice, +or at least skip exact duplicates by default. + +## 9. Offline map 🟢 + +`karte.html` loads Leaflet/jQuery/Bootstrap from CDNs and tiles from CartoDB, so +the map is blank without internet even though all the data is local. If offline +use matters, vendor the Leaflet JS/CSS into `data/` and post-process the Folium +output to point at local files (tiles would still need caching or an offline +tile pack — larger effort). + +## 10. Small code-quality items 🟢 — partly done + +- ✅ Shared autocomplete helpers (`filter_locations`, `split_city_plz`) + extracted; `EditDialog` and `App` both use them. +- ✅ Region list is now the `GEOCODE_REGIONS` constant; map centre/zoom are + `MAP_DEFAULT_CENTER` / `MAP_DEFAULT_ZOOM`. +- ✅ `generate_map` now `fit_bounds` to the markers. +- ⬜ Add a `tests/` folder with pytest coverage for `DataStore` (append / update + / delete / `find_duplicates`) and the input helpers. A non-GUI smoke script + exists (used during development) but is not in the repo — worth formalizing. +- ⬜ Add `ruff` / `mypy` config. + +--- + +## Suggested order of remaining work + +1. Initial git commit (item 4), then decide on committing `orte.csv`. +2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5). +3. Stable `id` column (item 7) and a modal duplicate prompt (item 8). +4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..d055316 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,85 @@ +# Overview – what the app does + +The app is a single window with a status bar at the bottom and two tabs. + +## Tab 1 – "Neuer Eintrag" (new entry) + +Workflow: build up a **queue** of entries, let them geocode in the background, +then save the whole batch at once. + +1. **Entry form** – date (a **calendar picker**, `ttkbootstrap.DateEntry`, + pre-filled with today; typed input is accepted in `JJJJ-MM-TT` or + `TT.MM.JJJJ` and **validated** – an invalid date is rejected with a dialog), + city (autocomplete combobox), postal code (optional). Pressing `Return` in any + field, or the **+ Hinzufügen** button, adds the row to the queue. +2. **Queue table** – shows date, city, PLZ, and a live coordinate column that + updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden` + as the background geocoder works through the queue. + - Left-click the `✕` cell to remove a row. + - Right-click a row for *Löschen* / *Koordinaten erneut suchen*. +3. **Action bar** + - **Alle speichern** – appends every queued row to `orte.csv`. If a row looks + like a duplicate of an existing entry (same date + city + PLZ) a hint is + shown in the status bar, but the row is **still saved**. + - **Leeren** – discards the queue without saving. + - **Karte öffnen** – regenerates `karte.html` and opens it in the browser. + +## Tab 2 – "Einträge verwalten" (manage entries) + +A table view of everything in `orte.csv`. + +- **Search box** – live filter across date, city, and PLZ (substring match). +- **Sortable columns** – click a header to sort; clicking again reverses. + Default sort is by date, newest first. +- **"Nur ohne Koordinaten"** toggle – filters to entries that have no + coordinates yet. Such rows are also shown in **red** in the full list, and the + hint line reports how many there are. +- **Edit** – double-click a row (or *Bearbeiten*) opens a modal dialog to change + date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates: + a "Koordinaten automatisch neu suchen" toggle re-runs geocoding after saving, + or the **Breitengrad / Längengrad** fields let you type them in by hand + (both empty = no map marker). +- **Right-click → Koordinaten suchen** – runs geocoding for that one row + (handy for rows that failed the first time). +- **Delete** – *Löschen* removes the selected row(s) after a confirmation + dialog. This is **irreversible**, but a timestamped copy of the file is + written to `data/backups/` before every change (see + [data-model.md](data-model.md#backups)). +- **Karte öffnen** – same as on Tab 1. + +## The map (`karte.html`) + +Generated by `generate_map()`: + +- Base layer: `CartoDB positron`, initial view centred on `[49.0, 9.0]`, + zoom 8 (roughly Baden-Württemberg). +- One **`CircleMarker`** per unique `(city, lat, lon)` group. Radius scales + linearly with the visit count for that location + (`5 + count / max_count * 20` px), colour is DRK red (`#CC0000`). +- Popup shows the city and the visit count; tooltip shows the city. +- Rows with missing/blank coordinates are silently excluded. +- If there are no usable coordinates at all, a `ValueError` is raised and shown + in the status bar. + +> The generated HTML pulls Leaflet, jQuery and Bootstrap from CDNs, so the map +> needs an internet connection to render even though the data is local. + +## Geocoding behaviour + +`geocode_city(city, postal_code)` tries a series of queries in order and returns +the first hit: + +1. `" , Germany"` (only if a PLZ was given) +2. `", Baden-Württemberg, Germany"` +3. `", Hessen, Germany"` +4. `", Germany"` + +The regional bias is the `GEOCODE_REGIONS` constant (`Baden-Württemberg`, +`Hessen`) near the top of `app.py`. Results are rounded to five decimal places. +Nominatim's usage policy (max 1 req/s, identifying `user_agent`) is respected by +a manual `time.sleep(1)` in the worker loop. + +If the geocoding service is unreachable (no internet), entries still save – +without coordinates – and after a few consecutive failures a one-time dialog +explains that the coordinates can be added later via *Bearbeiten*. The worker +thread logs failures to `data/app.log` and keeps running. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..838402b --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,105 @@ +# Setup & running + +Target machine: **Linux Mint laptop**, single non-technical user. The goal is +that day-to-day use is a **double-click**, with the terminal only needed once +during install. + +## 1. System packages + +Tkinter is not bundled with the system Python on Mint and must be installed +separately: + +```bash +sudo apt update +sudo apt install python3-tk python3-venv python3-pip +``` + +## 2. Get the code + +Put the project folder somewhere stable, e.g. `~/Apps/DRK_Blutspende_Orte`. + +## 3. Create a virtual environment and install dependencies + +```bash +cd ~/Apps/DRK_Blutspende_Orte +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +`requirements.txt`: + +``` +ttkbootstrap==1.10.1 +geopy>=2.4.1 +folium>=0.17.0 +pandas>=2.2.0 +``` + +> Only `ttkbootstrap` is pinned exactly. For a machine you hand to someone else, +> consider pinning all four (see [improvements.md](improvements.md#5-packaging--distribution)). + +## 4. Run it + +```bash +.venv/bin/python app.py +``` + +The window should open. `data/orte.csv` is created automatically on first run if +it is missing. + +## 5. Make it a double-click launcher + +### Launcher script + +Create `run.sh` in the project root: + +```bash +#!/usr/bin/env bash +cd "$(dirname "$0")" +exec .venv/bin/python app.py +``` + +```bash +chmod +x run.sh +``` + +### Desktop entry + +Create `~/.local/share/applications/drk-blutspende-orte.desktop`: + +```ini +[Desktop Entry] +Type=Application +Name=DRK Blutspende – Arbeitsorte +Comment=Einsatzorte protokollieren und auf der Karte anzeigen +Exec=/home/USER/Apps/DRK_Blutspende_Orte/run.sh +Icon=/home/USER/Apps/DRK_Blutspende_Orte/icon.png +Terminal=false +Categories=Utility; +``` + +Replace `USER` with the real username. Add any PNG as `icon.png` (the DRK logo +works well). The entry then shows up in the Mint menu and can be pinned to the +panel or the desktop. + +## Python / Tk version + +Python 3.9+ is fine (the code uses `from __future__ import annotations`). The +system Python on current Mint releases is well above that. + +`ttkbootstrap` needs **Tk 8.6 or newer**. Linux Mint's `python3-tk` provides +that. The macOS CommandLineTools Python used during development ships Tk 8.5 and +**cannot run the GUI** — see [dev-notes.md](dev-notes.md). + +## Updating + +```bash +cd ~/Apps/DRK_Blutspende_Orte +git pull # once the project is in git +.venv/bin/pip install -r requirements.txt +``` + +## Data location + +All state is in `data/orte.csv` next to `app.py`. To back up the app, copy that +one file. To move to a new laptop, copy the whole folder and redo steps 1 & 3. diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..4009055 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ttkbootstrap==1.10.1 +geopy>=2.4.1 +folium>=0.17.0 +pandas>=2.2.0