From 1bed723363dc095aac44673a74c96dad988b754b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Mon, 7 Sep 2026 22:06:51 +0200 Subject: [PATCH] Add in-app self-update via git (Hilfe menu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Updater class + "Hilfe" menubar: - "Nach Update suchen": git fetch + count of new upstream commits. - Quiet background check on start; surfaces via status bar + menu label. - Install: git merge --ff-only, pip install if requirements.txt changed, then restart via os.execv. data/ is git-ignored and untouched. - Fast-forward only; diverged history or offline -> clear message, no action. - Inert unless run from a git clone with git on PATH. Also: "Version…" menu item shows the installed commit. Verified against throwaway git repos (check / ff-update / diverged / no-op / non-clone) and via GUI build on Python 3.14 / Tk 9. Co-Authored-By: Claude Sonnet 5 --- app.py | 238 +++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 2 +- docs/architecture.md | 30 +++++- docs/changelog.md | 20 ++++ docs/improvements.md | 16 ++- docs/overview.md | 3 + docs/setup.md | 31 +++++- 7 files changed, 330 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index 1cf27d8..1bc8c88 100755 --- a/app.py +++ b/app.py @@ -12,6 +12,8 @@ import math import os import re import shutil +import subprocess +import sys import threading import time import webbrowser @@ -451,6 +453,90 @@ def generate_map(store: DataStore) -> Path: 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 ───────────────────────────────────────────────────────── @@ -612,6 +698,9 @@ class App(ttk.Window): 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 @@ -623,6 +712,9 @@ class App(ttk.Window): 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: @@ -684,6 +776,150 @@ class App(ttk.Window): 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( @@ -704,6 +940,8 @@ class App(ttk.Window): # ── 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) diff --git a/docs/README.md b/docs/README.md index aec4375..513a867 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,4 +31,4 @@ code) and plots them on an interactive map. - **UI language:** German; DRK-red header, enlarged font, colour-coded status bar, window position remembered - **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 +- **Version control:** git initialized 2026-09-07; in-app self-update (`git` fast-forward + restart) via the Hilfe menu diff --git a/docs/architecture.md b/docs/architecture.md index 45233d7..ed411e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,8 @@ Everything lives in [`app.py`](../app.py). There is no package structure. | `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. | +| `Updater` | Wraps `git` for self-update. `available` is true only from a git clone with `git` on `PATH`. `check()` → new-commit count (`fetch` + `rev-list`); `update()` → `fetch` + `merge --ff-only` + conditional `pip install`. All git calls are `subprocess.run` with timeouts. Raises `UpdateError` (user-facing German text) on any failure. | +| `App` | `ttkbootstrap.Window`. Builds the UI + menubar, owns the `DataStore`, the `GeocoderWorker`, the `Updater`, and the in-memory `_queue` list for Tab 1. | ## Data flow @@ -71,6 +72,33 @@ user clicks "Alle speichern" -> _on_save_all() app is open, the next in-app save overwrites those changes (but the pre-write backup captures them). +## Self-update flow + +``` +App start ─► daemon thread: sleep 2s ─► Updater.check() + (fetch + count) └─ on error: log only, stay quiet + │ + N > 0 ─► self.after(0, …) ─► status hint + menu label + + "Update verfügbar?" dialog + +Hilfe ▸ Nach Update suchen ─► worker thread ─► Updater.check() + │ + error ─► warning dialog (offline?) + N = 0 ─► "aktuell" dialog + N > 0 ─► "jetzt installieren?" dialog + +install ─► worker thread ─► Updater.update() (fetch, ff-only merge, pip) + success + changed ─► info dialog ─► _restart() + save window state, + stop geocoder, + os.chdir(BASE_DIR), + os.execv(python, [python, app.py]) + ff-only fails / pip fails ─► error dialog, no restart +``` + +Everything network- or subprocess-bound runs off the UI thread; results are +marshalled back with `self.after(0, …)` (same rule as the geocoder). + ## Logging `_setup_logging()` (called from `__main__`) attaches a `RotatingFileHandler` to diff --git a/docs/changelog.md b/docs/changelog.md index ebfa79d..7898f3d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,26 @@ Notable changes to the app. Newest first. +## 2026-09-07 — built-in self-update + +New **Hilfe** menu: + +- **Nach Update suchen** – `git fetch` + count of new commits on the server. +- **Version…** – shows the installed commit (` · `). + +On start, a quiet background check runs; if updates exist the status bar says so +and the menu item becomes "Update installieren (N verfügbar)". + +Installing runs `git merge --ff-only` (fast-forward only – never an automatic +merge), re-runs `pip install -r requirements.txt` if that file changed, then +restarts the app via `os.execv`. User data (`data/`) is git-ignored and +untouched. If the local checkout has diverged or the network is down, it shows a +clear message and does nothing. + +Only active when the app runs from a **git clone** with `git` installed; +otherwise the menu just shows "Version: unbekannt". New `Updater` class, +`app.py`. + ## 2026-09-07 — map tiles switched to OpenStreetMap `folium.Map(tiles="CartoDB positron")` began showing an "API KEY REQUIRED" diff --git a/docs/improvements.md b/docs/improvements.md index 075b384..ba273fd 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -212,9 +212,19 @@ Second review, focused on the interface. Items 1–12 implemented; 13 deferred. Full custom DRK-red *theme* (recolouring `primary` etc.) was **not** done — the red header bar gives the branding without fighting ttkbootstrap's theme system. +## Self-update (2026-09-07) — ✅ done + +**Hilfe ▸ Nach Update suchen** / quiet check on start / restart via `os.execv`. +`git merge --ff-only` only, `pip install` if `requirements.txt` changed, `data/` +untouched. Needs a git clone + `git` installed (`Updater.available`). +Open follow-ups: no rollback if a pushed update is broken (mitigated by +`--ff-only` from a branch Patrick controls + pip errors caught pre-restart); +`run.sh` / `.desktop` still not committed (item 5). + ## 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 of the first review). -4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real. +2. **Set up the git remote** the updater pulls from, and pin all deps (item 5). +3. `run.sh` + `.desktop` launcher in the repo (item 5). +4. Stable `id` column (item 7 of the first review). +5. Formalize tests (item 10); offline map (item 9) only if offline use becomes real. diff --git a/docs/overview.md b/docs/overview.md index 3bc828e..afd69a8 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,5 +1,8 @@ # Overview – what the app does +There is a **Hilfe** menu with "Nach Update suchen" (self-update via `git`, see +[setup.md](setup.md#updating)) and "Version…". + The app is a single window: a **DRK-red header bar**, two tabs, and a **colour-coded status bar** at the bottom (grey = neutral, green = success, orange = warning, red = error; transient messages fade back to neutral after diff --git a/docs/setup.md b/docs/setup.md index 9f060ce..17f888d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -7,16 +7,24 @@ during install. ## 1. System packages Tkinter is not bundled with the system Python on Mint and must be installed -separately: +separately. `git` is needed for the in-app updater: ```bash sudo apt update -sudo apt install python3-tk python3-venv python3-pip +sudo apt install python3-tk python3-venv python3-pip git ``` ## 2. Get the code -Put the project folder somewhere stable, e.g. `~/Apps/DRK_Blutspende_Orte`. +**Clone it** (don't download a zip) – the in-app "Nach Update suchen" only works +from a git clone: + +```bash +mkdir -p ~/Apps && cd ~/Apps +git clone DRK_Blutspende_Orte +``` + +Put it somewhere stable, e.g. `~/Apps/DRK_Blutspende_Orte`. ## 3. Create a virtual environment and install dependencies @@ -94,13 +102,26 @@ that. The macOS CommandLineTools Python used during development ships Tk 8.5 and ## Updating +**From inside the app:** menu **Hilfe ▸ Nach Update suchen**. The app also +checks quietly on start and offers the update if there is one. It fast-forwards +to the server version, reinstalls dependencies if `requirements.txt` changed, +and restarts itself. `data/` is never touched. + +For this to work the app must run from a **git clone** (step 2) whose `.venv` +was made with the same Python it runs on, and `git` must be installed. The +updater only ever fast-forwards – if the local copy has been changed by hand it +refuses and says to get in touch. + +**Manually** (equivalent): + ```bash cd ~/Apps/DRK_Blutspende_Orte -git pull # once the project is in git +git pull --ff-only .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. +one file (plus `data/backups/` if you want the history). To move to a new +laptop, clone the repo again and redo steps 1 & 3; copy `data/orte.csv` across.