Files
drk-blutspende-orte/docs/architecture.md
T
Paddy e3bbd4d264 Switch map tiles from CartoDB positron to OpenStreetMap (no API key)
CartoDB's positron basemap now renders an "API KEY REQUIRED" watermark.
Use tiles="OpenStreetMap" via a new MAP_TILES constant instead.

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

90 lines
5.1 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`. |
| `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 |
| OpenStreetMap tiles (`tile.openstreetmap.org`) | map background | blank/grey tiles |
Existing entries that already have coordinates do **not** need the network.