Files
drk-blutspende-orte/docs/architecture.md
T
Paddy 1bed723363 Add in-app self-update via git (Hilfe menu)
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 <noreply@anthropic.com>
2026-09-07 22:06:51 +02:00

6.7 KiB
Raw Blame History

Architecture

Everything lives in 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)
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.
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

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).

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 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
OpenStreetMap tiles (tile.openstreetmap.org) map background blank/grey tiles

Existing entries that already have coordinates do not need the network.