Files
drk-blutspende-orte/docs/architecture.md
T
Paddy 5eb60ec2d3 Map tiles: switch to Esri World Light Gray (OSM 403s file://)
tiles="OpenStreetMap" returned HTTP 403 "referer is required by tile usage
policy" in the target browser — OSM graylists refererless requests and a
karte.html opened via file:// sends no Referer. Esri's Canvas/World_Light_Gray
base + reference layers need neither a key nor a Referer and keep the muted
look. New MAP_TILES / MAP_TILES_LABELS / MAP_TILES_ATTR constants; generate_map
adds them as explicit TileLayers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 22:37:41 +02:00

118 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`. |
| `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 |
| Esri tiles (`server.arcgisonline.com`) | map background | blank/grey tiles |
Existing entries that already have coordinates do **not** need the network.