Tagebuch: manuelle Positionierung reichert POIs + Wetter an (v1305)

Bisher holte nur der Create-/Foto-EXIF-Pfad Wetter+POIs. Wer einem Eintrag
nachträglich (Edit) einen Standort gab, bekam nichts. Jetzt: GPS neu/geändert
im Update-Handler -> POIs immer + Wetter fürs Eintragsdatum.

- weather.get_weather_for_date(): heute -> aktuelles Wetter; Vergangenheit ->
  stündliche Historie (Open-Meteo Forecast <=90 Tage, sonst Archive-API),
  Stunde aus created_at. Gleiche Dict-Struktur wie get_weather_for_location.
- diary.update_diary(): erfasst alten GPS-Stand, reichert nach DB-Commit an
  (async), schreibt nur erfolgreich geholte Felder (kein Datenverlust bei
  API-Fehler), identische Koordinaten -> kein erneuter Abruf.
- Tests: tests/test_diary_location_enrich.py (Anreicherung, kein GPS=kein
  Abruf, Resave ohne Re-Fetch).
This commit is contained in:
rene 2026-06-18 21:13:47 +02:00
parent e2219fb8ba
commit 140140f690
8 changed files with 244 additions and 17 deletions

View file

@ -1 +1 @@
1304 1305

View file

@ -591,6 +591,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
if not exists: if not exists:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
# GPS-Stand VOR dem Update merken (für Anreicherungs-Entscheidung unten)
old = conn.execute(
"SELECT gps_lat, gps_lon FROM diary WHERE id=?", (entry_id,)
).fetchone()
old_lat = old["gps_lat"] if old else None
old_lon = old["gps_lon"] if old else None
# Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden # Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden
raw = data.model_dump(exclude={"dog_ids"}) raw = data.model_dump(exclude={"dog_ids"})
NULLABLE = {"location_name", "gps_lat", "gps_lon"} NULLABLE = {"location_name", "gps_lat", "gps_lon"}
@ -617,6 +624,49 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
dogs_map = _fetch_dog_ids(conn, [entry_id]) dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id]) media_map = _fetch_media_items(conn, [entry_id])
# Nachträgliche Positionierung: wurde GPS neu gesetzt oder geändert, POIs
# (immer) + Wetter fürs Eintragsdatum (historisch korrekt) nachladen —
# analog zum Create-Pfad, aber NACH dem DB-Commit (async HTTP).
new_lat, new_lon = row["gps_lat"], row["gps_lon"]
coords_changed = (
old_lat is None or old_lon is None
or round(old_lat, 5) != round(new_lat, 5)
or round(old_lon, 5) != round(new_lon, 5)
) if (new_lat is not None and new_lon is not None) else False
if coords_changed:
weather_json = None
poi_json = None
# Stunde fürs historische Wetter aus created_at, falls selber Tag wie datum.
hour = None
ca = row["created_at"]
if ca and len(ca) >= 13 and ca[:10] == (row["datum"] or "")[:10]:
try: hour = int(ca[11:13])
except (ValueError, TypeError): hour = None
try:
wd = await weather_mod.get_weather_for_date(new_lat, new_lon, row["datum"], hour)
weather_json = json.dumps(wd)
except Exception as exc:
logger.warning("Wetter-Anreicherung beim Diary-Update fehlgeschlagen: %s", exc)
try:
pois = await _fetch_pois_for_coords(new_lat, new_lon, limit=5)
if pois:
poi_json = json.dumps(pois)
except Exception as exc:
logger.warning("POI-Anreicherung beim Diary-Update fehlgeschlagen: %s", exc)
# Nur erfolgreich geholte Felder schreiben — ein API-Fehler überschreibt
# vorhandene Daten nicht (kein Datenverlust).
sets, vals = [], []
if weather_json is not None:
sets.append("weather_json=?"); vals.append(weather_json)
if poi_json is not None:
sets.append("poi_json=?"); vals.append(poi_json)
if sets:
with db() as conn:
conn.execute(f"UPDATE diary SET {', '.join(sets)} WHERE id=?", vals + [entry_id])
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
return _entry_dict(row, dogs_map, media_map) return _entry_dict(row, dogs_map, media_map)

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1304"></script> <script src="/js/boot-early.js?v=1305"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1304"> <link rel="stylesheet" href="/css/design-system.css?v=1305">
<link rel="stylesheet" href="/css/layout.css?v=1304"> <link rel="stylesheet" href="/css/layout.css?v=1305">
<link rel="stylesheet" href="/css/components.css?v=1304"> <link rel="stylesheet" href="/css/components.css?v=1305">
<link rel="stylesheet" href="/css/utilities.css?v=1304"> <link rel="stylesheet" href="/css/utilities.css?v=1305">
<link rel="stylesheet" href="/css/lists.css?v=1304"> <link rel="stylesheet" href="/css/lists.css?v=1305">
</head> </head>
<body> <body>
@ -624,12 +624,12 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1304"></script> <script src="/js/api.js?v=1305"></script>
<script src="/js/ui.js?v=1304"></script> <script src="/js/ui.js?v=1305"></script>
<script src="/js/app.js?v=1304"></script> <script src="/js/app.js?v=1305"></script>
<script src="/js/worlds.js?v=1304"></script> <script src="/js/worlds.js?v=1305"></script>
<script src="/js/offline-indicator.js?v=1304"></script> <script src="/js/offline-indicator.js?v=1305"></script>
<script src="/js/contact-form.js?v=1304"></script> <script src="/js/contact-form.js?v=1305"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -639,7 +639,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1304"></script> <script src="/js/boot.js?v=1305"></script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1304'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '1305'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1304"></script> <script src="/js/landing-init.js?v=1305"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go)."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go).">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1304'; const VER = '1305';
const CACHE_VERSION = `by-v${VER}`; const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -164,6 +164,88 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
_location_cache[key] = (now, data) _location_cache[key] = (now, data)
return data return data
async def get_weather_for_date(lat: float, lon: float, datum: str,
hour: int | None = None) -> dict:
"""Wetter für ein bestimmtes Datum (YYYY-MM-DD) an einem Standort.
Für nachträgliches Positionieren alter Tagebucheinträge: heutiges Datum
aktuelles Wetter; vergangene Tage stündliche Historie. Open-Meteo
Forecast-API deckt die jüngsten ~90 Tage Vergangenheit ab, ältere Tage
holt die Archive-API. `hour` (023, Default 12) wählt die Tagesstunde.
Rückgabe hat dieselbe Struktur wie get_weather_for_location (die nach
vorn gerichteten Felder next_rain_time/rain_warning_time sind None)."""
from datetime import date as _date
try:
target = _date.fromisoformat(datum[:10])
except (ValueError, TypeError):
return await get_weather_for_location(lat, lon)
today = datetime.now().date()
if target >= today:
# Heute/Zukunft → aktuelles Wetter (inkl. Vorhersage-Felder + Ortsname).
return await get_weather_for_location(lat, lon)
hour = 12 if hour is None else max(0, min(23, hour))
age_days = (today - target).days
base = ("https://archive-api.open-meteo.com/v1/archive"
if age_days > 90 else
"https://api.open-meteo.com/v1/forecast")
url = (
f"{base}?latitude={lat}&longitude={lon}"
f"&start_date={target.isoformat()}&end_date={target.isoformat()}"
"&hourly=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
"&daily=precipitation_probability_max,uv_index_max"
"&timezone=Europe%2FBerlin"
)
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
raw = resp.json()
hourly = raw.get('hourly', {})
daily = raw.get('daily', {})
def _at(arr):
try:
return arr[hour]
except (IndexError, TypeError, KeyError):
return None
temp = _at(hourly.get('temperature_2m', []))
feels_like = _at(hourly.get('apparent_temperature', []))
wcode = _at(hourly.get('weathercode', [])) or 0
wind = _at(hourly.get('windspeed_10m', []))
is_day_v = _at(hourly.get('is_day', []))
is_day = 1 if is_day_v is None else is_day_v
precip = (daily.get('precipitation_probability_max') or [None])[0]
uv = (daily.get('uv_index_max') or [None])[0]
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
if wcode == 0 and not is_day:
icon = 'moon'
zecken = None
if temp is not None and temp > 7.0 and 3 <= target.month <= 10:
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
return {
'temp_c': temp,
'feels_like_c': feels_like,
'weathercode': wcode,
'desc': desc,
'icon': icon,
'wind_kmh': wind,
'precip_prob': precip,
'uv_index': uv,
'is_day': bool(is_day),
'zecken_warnung': zecken,
'next_rain_time': None,
'rain_warning_time': None,
'location_name': None,
'historical': True,
}
# WMO-Wettercodes 9599 = Gewitter # WMO-Wettercodes 9599 = Gewitter
THUNDERSTORM_CODES = {95, 96, 99} THUNDERSTORM_CODES = {95, 96, 99}

View file

@ -0,0 +1,95 @@
"""Nachträgliches Positionieren eines Tagebucheintrags reichert POIs + Wetter an.
Deckt die Lücke ab: Der PATCH-/Update-Pfad holte bisher kein Wetter/POI, wenn
man einem Eintrag (z.B. Indoor-Foto ohne EXIF-GPS) nachträglich einen Standort
gibt. Jetzt: GPS neu gesetzt/geändert POIs (immer) + Wetter fürs Eintragsdatum
(historisch korrekt via get_weather_for_date).
Wetter- und POI-Fetch sind gestubbt (kein echtes Netzwerk im Test).
"""
from __future__ import annotations
import json
import pytest
@pytest.fixture
def stub_enrich(monkeypatch, app):
# WICHTIG: routes.diary erst HIER importieren (nach dem app-Fixture, das
# DB_PATH setzt) — ein Top-Level-Import würde database mit dem Default-Pfad
# binden und die ganze Test-Session vergiften.
import routes.diary as diary_mod
calls = {}
async def _fake_weather(lat, lon, datum, hour=None):
calls["weather"] = {"lat": lat, "lon": lon, "datum": datum, "hour": hour}
return {"temp_c": 18.5, "weathercode": 61, "desc": "Regen",
"is_day": True, "historical": True}
async def _fake_pois(lat, lon, limit=5):
calls["poi"] = {"lat": lat, "lon": lon}
return [{"name": "Stadtpark", "type": "park", "distance_m": 120}]
monkeypatch.setattr(diary_mod.weather_mod, "get_weather_for_date", _fake_weather)
monkeypatch.setattr(diary_mod, "_fetch_pois_for_coords", _fake_pois)
return calls
def _create_without_gps(client, user, dog, datum="2026-06-12"):
r = client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Ohne Standort", "text": "Indoor-Eintrag ohne GPS.", "datum": datum},
)
assert r.status_code == 201, r.text
e = r.json()
assert not e.get("weather_json") and not e.get("poi_json")
return e
def _loads(v):
return json.loads(v) if isinstance(v, str) else v
def test_manual_position_enriches_weather_and_pois(client, user, dog, stub_enrich):
entry = _create_without_gps(client, user, dog)
r = client.patch(
f"/api/dogs/{dog['id']}/diary/{entry['id']}",
headers=user["headers"],
json={"gps_lat": 48.137, "gps_lon": 11.575, "location_name": "München"},
)
assert r.status_code == 200, r.text
out = r.json()
w = _loads(out["weather_json"])
p = _loads(out["poi_json"])
assert w["weathercode"] == 61
assert p[0]["name"] == "Stadtpark"
assert out["location_name"] == "München"
# Wetter wurde fürs EINTRAGSDATUM geholt (historisch korrekt), nicht "heute".
assert stub_enrich["weather"]["datum"] == "2026-06-12"
def test_edit_without_gps_does_not_enrich(client, user, dog, stub_enrich):
entry = _create_without_gps(client, user, dog)
r = client.patch(
f"/api/dogs/{dog['id']}/diary/{entry['id']}",
headers=user["headers"],
json={"titel": "Nur Titel geändert"},
)
assert r.status_code == 200, r.text
assert "weather" not in stub_enrich and "poi" not in stub_enrich
def test_resave_same_coords_does_not_refetch(client, user, dog, stub_enrich):
entry = _create_without_gps(client, user, dog)
# 1. Positionierung → reichert an
client.patch(f"/api/dogs/{dog['id']}/diary/{entry['id']}", headers=user["headers"],
json={"gps_lat": 48.137, "gps_lon": 11.575})
assert "weather" in stub_enrich
stub_enrich.clear()
# erneut speichern mit IDENTISCHEN Koordinaten → keine erneute Anreicherung
r = client.patch(f"/api/dogs/{dog['id']}/diary/{entry['id']}", headers=user["headers"],
json={"gps_lat": 48.137, "gps_lon": 11.575})
assert r.status_code == 200
assert "weather" not in stub_enrich and "poi" not in stub_enrich