0b6ed854dc
Bound to <Button-3> with finally: menu.grab_release(), the menu lost its grab immediately and the trailing <ButtonRelease-3> activated the entry under the cursor — right-clicking a history row jumped straight into the edit dialog and deleting was impossible. - RIGHT_CLICK constant: <ButtonRelease-3> (<ButtonRelease-2> on macOS) - history + queue menus built once on self, not per click - drop the premature grab_release() Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1567 lines
63 KiB
Python
Executable File
1567 lines
63 KiB
Python
Executable File
"""
|
||
DRK Blutspende – Arbeitsorte Logger
|
||
Protokolliert Arbeitseinsätze mit Datum und Ort (mit PLZ).
|
||
Visualisiert Einsatzorte auf einer interaktiven Karte.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
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
|
||
import tkinter.font as tkfont
|
||
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"
|
||
ICON_PATH = BASE_DIR / "icon.png" # mitgeliefert (für Fenster + Startmenü)
|
||
ICON_FALLBACK_PATH = DATA_DIR / "icon.png" # zur Laufzeit gezeichnet, falls obiges fehlt
|
||
WINDOW_STATE_PATH = DATA_DIR / "window.json"
|
||
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
|
||
|
||
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
|
||
MAX_BACKUPS = 20
|
||
|
||
#: Schriftgröße der Oberfläche (größer = besser lesbar auf einem Laptop).
|
||
UI_FONT_SIZE = 11
|
||
|
||
#: DRK-Rot – für Akzente in der Oberfläche und die Kartenpunkte.
|
||
DRK_RED = "#CC0000"
|
||
|
||
#: Rechtsklick-Event. ``<ButtonRelease-…>`` statt ``<Button-…>``: sonst landet
|
||
#: unter X11 das Loslassen der Maustaste direkt im ersten Menüeintrag.
|
||
RIGHT_CLICK = "<ButtonRelease-2>" if sys.platform == "darwin" else "<ButtonRelease-3>"
|
||
|
||
#: 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
|
||
|
||
#: Kartenkacheln – Esri "World Light Gray" (heller, ruhiger Hintergrund).
|
||
#: Braucht keinen API-Schlüssel und keinen Referer (anders als tile.openstreetmap.org,
|
||
#: das lokale Dateien mit 403 blockt, und "CartoDB positron", das einen Schlüssel will).
|
||
#: Alternativen ohne Schlüssel als Ein-Zeilen-Tausch:
|
||
#: Esri Straßenkarte: …/World_Street_Map/MapServer/tile/{z}/{y}/{x}
|
||
#: Esri Topo: …/World_Topo_Map/MapServer/tile/{z}/{y}/{x}
|
||
_ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services"
|
||
MAP_TILES = f"{_ESRI}/Canvas/World_Light_Gray_Base/MapServer/tile/{{z}}/{{y}}/{{x}}"
|
||
MAP_TILES_LABELS = f"{_ESRI}/Canvas/World_Light_Gray_Reference/MapServer/tile/{{z}}/{{y}}/{{x}}"
|
||
MAP_TILES_ATTR = "Kacheln © Esri"
|
||
|
||
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=None,
|
||
)
|
||
folium.TileLayer(
|
||
MAP_TILES, attr=MAP_TILES_ATTR, name="Karte", control=False,
|
||
).add_to(m)
|
||
folium.TileLayer(
|
||
MAP_TILES_LABELS, attr=MAP_TILES_ATTR, name="Beschriftung",
|
||
control=False, overlay=True,
|
||
).add_to(m)
|
||
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"<b>{row['city']}</b><br>{count} Einsatz/Einsätze", max_width=200
|
||
),
|
||
tooltip=str(row["city"]),
|
||
color=DRK_RED,
|
||
fill=True,
|
||
fill_color=DRK_RED,
|
||
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
|
||
|
||
|
||
# ── Selbst-Update (git) ──────────────────────────────────────────────────────
|
||
|
||
|
||
class UpdateError(Exception):
|
||
"""Ein Schritt beim Aktualisieren ist fehlgeschlagen (Text ist benutzertauglich)."""
|
||
|
||
|
||
class Updater:
|
||
"""Aktualisiert die App per ``git`` – nur wenn sie aus einem Git-Klon läuft.
|
||
|
||
Fährt bewusst konservativ: es wird ausschließlich per *fast-forward* auf den
|
||
Stand des Servers gezogen. Lokale Änderungen oder abweichende Historie führen
|
||
zu einer klaren Fehlermeldung statt zu einem automatischen Merge.
|
||
"""
|
||
|
||
def __init__(self, base_dir: Path) -> None:
|
||
self.base_dir = base_dir
|
||
self.available = bool(shutil.which("git")) and (base_dir / ".git").is_dir()
|
||
|
||
def _git(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
||
return subprocess.run(
|
||
["git", "-C", str(self.base_dir), *args],
|
||
capture_output=True, text=True, timeout=timeout,
|
||
)
|
||
|
||
def _upstream_ref(self) -> str:
|
||
r = self._git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||
return r.stdout.strip() or "origin/main"
|
||
|
||
def current_version(self) -> str:
|
||
r = self._git("log", "-1", "--format=%h · %cs")
|
||
return r.stdout.strip() or "unbekannt"
|
||
|
||
def check(self) -> int:
|
||
"""Anzahl neuer Commits auf dem Server. Wirft ``UpdateError`` bei Problemen."""
|
||
f = self._git("fetch", "--quiet", timeout=60)
|
||
if f.returncode != 0:
|
||
raise UpdateError(f.stderr.strip() or "Server nicht erreichbar (git fetch).")
|
||
ref = self._upstream_ref()
|
||
c = self._git("rev-list", "--count", f"HEAD..{ref}")
|
||
if c.returncode != 0:
|
||
raise UpdateError(c.stderr.strip() or "Versionsvergleich fehlgeschlagen.")
|
||
return int(c.stdout.strip() or "0")
|
||
|
||
def update(self) -> bool:
|
||
"""Zieht den neuen Stand (fast-forward) und installiert ggf. neue Pakete.
|
||
|
||
Gibt ``True`` zurück, wenn tatsächlich etwas aktualisiert wurde.
|
||
"""
|
||
f = self._git("fetch", "--quiet", timeout=60)
|
||
if f.returncode != 0:
|
||
raise UpdateError(f.stderr.strip() or "Server nicht erreichbar (git fetch).")
|
||
ref = self._upstream_ref()
|
||
before = self._git("rev-parse", "HEAD").stdout.strip()
|
||
m = self._git("merge", "--ff-only", ref, timeout=60)
|
||
if m.returncode != 0:
|
||
raise UpdateError(
|
||
(m.stderr.strip() or "Automatisches Update nicht möglich.")
|
||
+ "\n\nBitte Patrick Bescheid geben."
|
||
)
|
||
after = self._git("rev-parse", "HEAD").stdout.strip()
|
||
if before == after:
|
||
return False
|
||
changed = self._git("diff", "--name-only", before, after).stdout.split()
|
||
if "requirements.txt" in changed:
|
||
self._install_requirements()
|
||
log.info("Update: %s → %s", before[:9], after[:9])
|
||
return True
|
||
|
||
def _install_requirements(self) -> None:
|
||
req = self.base_dir / "requirements.txt"
|
||
if not req.exists():
|
||
return
|
||
r = subprocess.run(
|
||
[sys.executable, "-m", "pip", "install", "-r", str(req)],
|
||
capture_output=True, text=True, timeout=600,
|
||
)
|
||
if r.returncode != 0:
|
||
raise UpdateError(
|
||
"Neue Programmbibliotheken konnten nicht installiert werden:\n"
|
||
+ (r.stderr.strip()[-400:] or "pip-Fehler")
|
||
)
|
||
|
||
|
||
# ── 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("<KeyRelease>", self._on_city_key)
|
||
self._city_cb.bind("<<ComboboxSelected>>", 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("<Return>", lambda _: self._on_save())
|
||
self.bind("<Escape>", 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):
|
||
#: Statusmeldungs-Art → ttkbootstrap-Style der Statusleiste.
|
||
_STATUS_STYLES = {
|
||
"info": "secondary", "success": "success",
|
||
"warning": "warning", "error": "danger",
|
||
}
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(themename="litera")
|
||
self.minsize(820, 620)
|
||
self._scale_fonts()
|
||
self._install_icon()
|
||
|
||
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.updater = Updater(BASE_DIR)
|
||
self._update_busy = False
|
||
|
||
self._queue: list[dict] = []
|
||
self._next_id = 0
|
||
self._status_after_id: str | None = None
|
||
|
||
self._build_ui()
|
||
self._update_title()
|
||
self._restore_window_state()
|
||
self._reset_status()
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
log.info("App gestartet (%d Einträge).", self.store.total_count())
|
||
|
||
if self.updater.available:
|
||
threading.Thread(target=self._auto_check_update, daemon=True).start()
|
||
|
||
# ── Aussehen ──────────────────────────────────────────────────────────────
|
||
|
||
def _scale_fonts(self) -> None:
|
||
"""Vergrößert die Standard-Schriftarten – deutlich besser lesbar."""
|
||
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont"):
|
||
try:
|
||
tkfont.nametofont(name).configure(size=UI_FONT_SIZE)
|
||
except tk.TclError:
|
||
pass
|
||
self._ui_family = tkfont.nametofont("TkDefaultFont").actual("family")
|
||
self.style.configure(".", font=(self._ui_family, UI_FONT_SIZE))
|
||
self.style.configure(
|
||
"Treeview", font=(self._ui_family, UI_FONT_SIZE),
|
||
rowheight=UI_FONT_SIZE * 2 + 8,
|
||
)
|
||
self.style.configure(
|
||
"Treeview.Heading", font=(self._ui_family, UI_FONT_SIZE, "bold")
|
||
)
|
||
|
||
def _install_icon(self) -> None:
|
||
"""Setzt das Fenster-Icon: mitgelieferte icon.png, sonst ein gezeichnetes Kreuz."""
|
||
try:
|
||
if ICON_PATH.exists():
|
||
self._icon_img = tk.PhotoImage(file=str(ICON_PATH))
|
||
self.iconphoto(True, self._icon_img)
|
||
return
|
||
size = 64
|
||
img = tk.PhotoImage(width=size, height=size)
|
||
img.put("white", to=(0, 0, size, size))
|
||
arm = size // 3
|
||
img.put(DRK_RED, to=(arm, 7, size - arm, size - 7))
|
||
img.put(DRK_RED, to=(7, arm, size - 7, size - arm))
|
||
self.iconphoto(True, img)
|
||
self._icon_img = img # Referenz halten
|
||
try:
|
||
img.write(str(ICON_FALLBACK_PATH), format="png")
|
||
except tk.TclError:
|
||
pass # Tk < 8.6 kann kein PNG schreiben – nicht kritisch
|
||
except tk.TclError:
|
||
log.warning("Fenster-Icon konnte nicht gesetzt werden.")
|
||
|
||
def _update_title(self) -> None:
|
||
n = self.store.total_count()
|
||
self.title(f"DRK Blutspende – Arbeitsorte · {n} Einsätze")
|
||
|
||
# ── Fensterposition merken ────────────────────────────────────────────────
|
||
|
||
def _restore_window_state(self) -> None:
|
||
try:
|
||
geom = json.loads(WINDOW_STATE_PATH.read_text()).get("geometry")
|
||
except (OSError, ValueError):
|
||
return
|
||
if isinstance(geom, str) and re.match(r"^\d+x\d+\+-?\d+\+-?\d+$", geom):
|
||
try:
|
||
self.geometry(geom)
|
||
except tk.TclError:
|
||
pass
|
||
|
||
def _save_window_state(self) -> None:
|
||
try:
|
||
WINDOW_STATE_PATH.write_text(json.dumps({"geometry": self.geometry()}))
|
||
except OSError:
|
||
log.warning("Fensterposition konnte nicht gespeichert werden.")
|
||
|
||
# ── Menüleiste & Selbst-Update ───────────────────────────────────────────
|
||
|
||
def _build_menubar(self) -> None:
|
||
menubar = tk.Menu(self)
|
||
help_menu = tk.Menu(menubar, tearoff=0)
|
||
if self.updater.available:
|
||
self._update_menu = help_menu
|
||
self._update_menu_index = 0
|
||
help_menu.add_command(
|
||
label="Nach Update suchen", command=lambda: self._check_update(manual=True)
|
||
)
|
||
help_menu.add_separator()
|
||
else:
|
||
self._update_menu = None
|
||
help_menu.add_command(label="Version…", command=self._show_version)
|
||
menubar.add_cascade(label="Hilfe", menu=help_menu)
|
||
self.configure(menu=menubar)
|
||
|
||
def _show_version(self) -> None:
|
||
if self.updater.available:
|
||
info = f"Installierte Version:\n{self.updater.current_version()}"
|
||
else:
|
||
info = "Version: unbekannt (kein Git-Klon – Update über das Menü nicht möglich)."
|
||
messagebox.showinfo("DRK Blutspende – Arbeitsorte", info, parent=self)
|
||
|
||
def _auto_check_update(self) -> None:
|
||
"""Stiller Hintergrund-Check beim Start – meldet sich nur, wenn es etwas gibt."""
|
||
time.sleep(2)
|
||
try:
|
||
n = self.updater.check()
|
||
except Exception as exc: # offline o. Ä. – nicht stören
|
||
log.info("Automatischer Update-Check übersprungen: %s", exc)
|
||
return
|
||
if n > 0:
|
||
self.after(0, lambda: self._on_update_available(n, manual=False))
|
||
|
||
def _check_update(self, *, manual: bool) -> None:
|
||
if self._update_busy:
|
||
return
|
||
if not self.updater.available:
|
||
messagebox.showinfo(
|
||
"Update", "Diese Installation kann sich nicht selbst aktualisieren.",
|
||
parent=self,
|
||
)
|
||
return
|
||
self._update_busy = True
|
||
self._set_status("Suche nach Updates…", sticky=True)
|
||
|
||
def work() -> None:
|
||
try:
|
||
n = self.updater.check()
|
||
err = None
|
||
except Exception as exc:
|
||
n, err = 0, str(exc)
|
||
self.after(0, self._on_update_checked, n, err, manual)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
def _on_update_checked(self, n: int, err: str | None, manual: bool) -> None:
|
||
self._update_busy = False
|
||
if err is not None:
|
||
self._set_status("Update-Prüfung fehlgeschlagen.", "warning")
|
||
if manual:
|
||
messagebox.showwarning(
|
||
"Update",
|
||
f"Es konnte nicht nach Updates gesucht werden:\n\n{err}\n\n"
|
||
"Ist der Computer mit dem Internet verbunden?",
|
||
parent=self,
|
||
)
|
||
return
|
||
if n == 0:
|
||
self._set_status("Die App ist auf dem neuesten Stand.", "success")
|
||
if manual:
|
||
messagebox.showinfo("Update", "Die App ist auf dem neuesten Stand.", parent=self)
|
||
return
|
||
self._on_update_available(n, manual=manual)
|
||
|
||
def _on_update_available(self, n: int, *, manual: bool) -> None:
|
||
if self._update_menu is not None:
|
||
self._update_menu.entryconfigure(
|
||
self._update_menu_index, label=f"Update installieren ({n} verfügbar)"
|
||
)
|
||
self._set_status(
|
||
f"{n} Aktualisierung(en) verfügbar – Menü „Hilfe“ › Update installieren.",
|
||
"info", sticky=True,
|
||
)
|
||
count = f"{n} Aktualisierung" + ("en" if n != 1 else "")
|
||
if messagebox.askyesno(
|
||
"Update verfügbar",
|
||
f"Es gibt {count} für diese App.\n\n"
|
||
"Jetzt installieren? Die App wird dabei kurz neu gestartet.\n"
|
||
"Deine gespeicherten Einträge bleiben unverändert.",
|
||
parent=self,
|
||
):
|
||
self._do_update()
|
||
|
||
def _do_update(self) -> None:
|
||
if self._update_busy:
|
||
return
|
||
self._update_busy = True
|
||
self._set_status("App wird aktualisiert – bitte warten…", sticky=True)
|
||
|
||
def work() -> None:
|
||
try:
|
||
changed = self.updater.update()
|
||
err = None
|
||
except Exception as exc:
|
||
changed, err = False, str(exc)
|
||
self.after(0, self._on_update_done, changed, err)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
def _on_update_done(self, changed: bool, err: str | None) -> None:
|
||
self._update_busy = False
|
||
if err is not None:
|
||
self._set_status("Update fehlgeschlagen.", "error")
|
||
messagebox.showerror("Update fehlgeschlagen", err, parent=self)
|
||
return
|
||
if not changed:
|
||
self._set_status("Nichts zu aktualisieren.", "info")
|
||
return
|
||
messagebox.showinfo(
|
||
"Update installiert",
|
||
"Die Aktualisierung wurde installiert. Die App startet jetzt neu.",
|
||
parent=self,
|
||
)
|
||
self._restart()
|
||
|
||
def _restart(self) -> None:
|
||
log.info("Neustart nach Update.")
|
||
self._save_window_state()
|
||
self.geocoder.stop()
|
||
try:
|
||
os.chdir(BASE_DIR)
|
||
os.execv(sys.executable, [sys.executable, str(Path(__file__).resolve())])
|
||
except OSError:
|
||
log.exception("Neustart fehlgeschlagen.")
|
||
messagebox.showwarning(
|
||
"Neustart nötig",
|
||
"Bitte die App einmal schließen und neu öffnen, "
|
||
"damit das Update wirksam wird.",
|
||
parent=self,
|
||
)
|
||
|
||
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.",
|
||
"error",
|
||
)
|
||
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:
|
||
self._build_menubar()
|
||
|
||
# Kopfzeile mit DRK-Rot
|
||
header = ttk.Frame(self, bootstyle="danger", padding=(14, 8))
|
||
header.pack(fill=X, side=TOP)
|
||
ttk.Label(
|
||
header, text="DRK Blutspende – Arbeitsorte",
|
||
bootstyle="inverse-danger",
|
||
font=(self._ui_family, UI_FONT_SIZE + 4, "bold"),
|
||
).pack(side=LEFT)
|
||
|
||
# Statusleiste (ganz unten, vor Notebook packen)
|
||
self._status_var = ttk.StringVar()
|
||
self._status_label = ttk.Label(
|
||
self, textvariable=self._status_var, bootstyle="secondary",
|
||
anchor=W, padding=(12, 5),
|
||
)
|
||
self._status_label.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("<<NotebookTabChanged>>", 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))
|
||
|
||
plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
|
||
|
||
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=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, 16), pady=4)
|
||
|
||
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4), pady=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, 16), pady=4)
|
||
self.city_cb.bind("<KeyRelease>", self._on_city_key)
|
||
self.city_cb.bind("<<ComboboxSelected>>", self._on_city_selected)
|
||
|
||
ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4), pady=4)
|
||
self._plz_var = ttk.StringVar()
|
||
plz_entry = ttk.Entry(
|
||
input_frame, textvariable=self._plz_var, width=8,
|
||
validate="key", validatecommand=plz_ok,
|
||
)
|
||
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 16), pady=4)
|
||
|
||
add_btn = ttk.Button(
|
||
input_frame, text="+ Hinzufügen", bootstyle="success",
|
||
command=self._on_add_row,
|
||
)
|
||
add_btn.grid(row=0, column=6, pady=4)
|
||
|
||
for w in (self._date_entry.entry, self.city_cb, plz_entry):
|
||
w.bind("<Return>", 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._queue_menu = tk.Menu(self, tearoff=0)
|
||
self._queue_menu.add_command(
|
||
label="Entfernen", command=self._delete_selected_queue_rows
|
||
)
|
||
self._queue_menu.add_command(
|
||
label="Koordinaten erneut suchen", command=self._retry_selected_geocode
|
||
)
|
||
|
||
self.tree.bind("<ButtonRelease-1>", self._on_tree_click)
|
||
self.tree.bind(RIGHT_CLICK, self._on_queue_right_click)
|
||
self.tree.bind("<Delete>", lambda _e: self._delete_selected_queue_rows())
|
||
|
||
# Aktionsleiste
|
||
action_frame = ttk.Frame(parent, padding=(4, 6))
|
||
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="Auswahl entfernen", bootstyle="secondary-outline",
|
||
command=self._delete_selected_queue_rows).pack(side=LEFT, padx=(0, 6))
|
||
ttk.Button(action_frame, text="Warteschlange 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)
|
||
|
||
self._hist_headings = {
|
||
"date": "Datum", "city": "Ort", "postal_code": "PLZ", "coords": "Karte",
|
||
}
|
||
cols = tuple(self._hist_headings)
|
||
self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
|
||
for key in cols:
|
||
self.hist_tree.heading(
|
||
key, text=self._hist_headings[key],
|
||
command=lambda k=key: self._sort_history(k),
|
||
)
|
||
self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
|
||
self.hist_tree.column("city", width=260, minwidth=140)
|
||
self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
|
||
self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
|
||
|
||
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=DRK_RED)
|
||
|
||
self._hist_menu = tk.Menu(self, tearoff=0)
|
||
self._hist_menu.add_command(label="Bearbeiten…", command=self._on_hist_edit)
|
||
self._hist_menu.add_command(
|
||
label="Koordinaten suchen", command=self._on_hist_geocode
|
||
)
|
||
self._hist_menu.add_separator()
|
||
self._hist_menu.add_command(label="Löschen", command=self._on_hist_delete)
|
||
|
||
self.hist_tree.bind("<Double-1>", self._on_hist_double_click)
|
||
self.hist_tree.bind(RIGHT_CLICK, self._on_hist_right_click)
|
||
|
||
self._sort_col = "date"
|
||
self._sort_asc = False # neueste zuerst
|
||
self._apply_sort_headings()
|
||
|
||
# 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()
|
||
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 = len(df)
|
||
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)]
|
||
|
||
if self._sort_col == "coords":
|
||
order = missing.reindex(df.index, fill_value=True)
|
||
df = df.assign(_ord=order).sort_values("_ord", ascending=self._sort_asc)
|
||
else:
|
||
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", ""),
|
||
"fehlt" if row_missing else "✓",
|
||
),
|
||
)
|
||
|
||
parts = [f"{total} Einsätze gespeichert"]
|
||
if total_missing:
|
||
parts.append(
|
||
f"{total_missing} ohne Koordinaten (rot) – Rechtsklick › Koordinaten suchen"
|
||
)
|
||
else:
|
||
parts.append("Doppelklick zum Bearbeiten")
|
||
self._hist_hint_var.set(" · ".join(parts))
|
||
|
||
def _apply_sort_headings(self) -> None:
|
||
"""Zeigt einen Pfeil an der aktuell sortierten Spalte."""
|
||
arrow = " ▲" if self._sort_asc else " ▼"
|
||
for key, label in self._hist_headings.items():
|
||
self.hist_tree.heading(
|
||
key, text=label + (arrow if key == self._sort_col else "")
|
||
)
|
||
|
||
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._apply_sort_headings()
|
||
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)
|
||
self.hist_tree.focus(iid)
|
||
self._hist_menu.tk_popup(event.x_root, event.y_root)
|
||
|
||
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']}.",
|
||
"success",
|
||
)
|
||
|
||
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("Koordinaten aktualisiert.", "success")
|
||
else:
|
||
self._set_status(
|
||
"Koordinaten konnten nicht gefunden werden – ggf. manuell eintragen.",
|
||
"warning",
|
||
)
|
||
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._update_title()
|
||
self._refresh_history()
|
||
self._set_status(
|
||
f"{count} Eintrag/Einträge gelöscht · {self.store.total_count()} insgesamt.",
|
||
"warning",
|
||
)
|
||
|
||
# ── 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.", "warning")
|
||
return
|
||
if date_str is None:
|
||
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning")
|
||
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.", sticky=True
|
||
)
|
||
|
||
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
|
||
if row_id not in self.tree.selection():
|
||
self.tree.selection_set(row_id)
|
||
self._queue_menu.tk_popup(event.x_root, event.y_root)
|
||
|
||
def _retry_selected_geocode(self) -> None:
|
||
for row_id in self.tree.selection():
|
||
self._retry_geocode(row_id)
|
||
|
||
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.", sticky=True
|
||
)
|
||
|
||
def _delete_selected_queue_rows(self) -> None:
|
||
sel = self.tree.selection()
|
||
if not sel:
|
||
self._set_status("Bitte zuerst eine Zeile in der Warteschlange auswählen.")
|
||
return
|
||
for row_id in sel:
|
||
self._delete_queue_row(row_id)
|
||
|
||
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:
|
||
if self._queue and not messagebox.askyesno(
|
||
"Warteschlange leeren",
|
||
f"{len(self._queue)} noch nicht gespeicherte Einträge verwerfen?",
|
||
parent=self,
|
||
):
|
||
return
|
||
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:
|
||
listed = "\n".join(f" • {d['date']} {d['city']}" for d in dupes[:8])
|
||
more = f"\n … und {len(dupes) - 8} weitere" if len(dupes) > 8 else ""
|
||
if not messagebox.askyesno(
|
||
"Mögliche Duplikate",
|
||
f"{len(dupes)} Eintrag/Einträge scheint es schon zu geben:\n\n"
|
||
f"{listed}{more}\n\nTrotzdem alle speichern?",
|
||
parent=self,
|
||
):
|
||
self._set_status("Speichern abgebrochen – Warteschlange unverändert.")
|
||
return
|
||
|
||
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._update_title()
|
||
self._refresh_history() # Tab 2 sofort aktualisieren
|
||
self._set_status(
|
||
f"{count} Eintrag/Einträge gespeichert · {self.store.total_count()} insgesamt.",
|
||
"success",
|
||
)
|
||
|
||
# ── Karte ─────────────────────────────────────────────────────────────────
|
||
|
||
def _on_open_map(self) -> None:
|
||
try:
|
||
path = generate_map(self.store)
|
||
except ValueError as e:
|
||
messagebox.showinfo("Karte", str(e), parent=self)
|
||
self._set_status(str(e), "warning")
|
||
return
|
||
except Exception as e:
|
||
log.exception("Karte konnte nicht erstellt werden.")
|
||
messagebox.showerror(
|
||
"Karte", f"Die Karte konnte nicht erstellt werden:\n{e}", parent=self
|
||
)
|
||
self._set_status(f"Fehler beim Erstellen der Karte: {e}", "error")
|
||
return
|
||
try:
|
||
webbrowser.open(path.as_uri())
|
||
except Exception:
|
||
log.exception("Karte konnte nicht im Browser geöffnet werden.")
|
||
self._set_status("Karte im Browser geöffnet.", "success")
|
||
|
||
# ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||
|
||
def _set_status(self, msg: str, kind: str = "info", *, sticky: bool = False) -> None:
|
||
"""Zeigt eine Statusmeldung, farblich nach Art, und blendet sie nach 8 s aus."""
|
||
self._status_var.set(msg)
|
||
self._status_label.configure(
|
||
bootstyle=self._STATUS_STYLES.get(kind, "secondary")
|
||
)
|
||
if self._status_after_id is not None:
|
||
self.after_cancel(self._status_after_id)
|
||
self._status_after_id = None
|
||
if not sticky:
|
||
self._status_after_id = self.after(8000, self._reset_status)
|
||
|
||
def _reset_status(self) -> None:
|
||
"""Setzt die Statusleiste auf den neutralen Dauerzustand zurück."""
|
||
self._status_after_id = None
|
||
self._status_label.configure(bootstyle="secondary")
|
||
if self._queue:
|
||
self._status_var.set(
|
||
f"{len(self._queue)} Einträge in der Warteschlange – noch nicht gespeichert."
|
||
)
|
||
else:
|
||
self._status_var.set("Bereit.")
|
||
|
||
def _on_close(self) -> None:
|
||
log.info("App wird beendet.")
|
||
self._save_window_state()
|
||
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
|