diff --git a/VERSION b/VERSION index b9f7ba3..880a8fb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1304 \ No newline at end of file +1305 \ No newline at end of file diff --git a/backend/routes/diary.py b/backend/routes/diary.py index c122e92..5450fef 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -591,6 +591,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, if not exists: 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 raw = data.model_dump(exclude={"dog_ids"}) 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]) 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) diff --git a/backend/static/index.html b/backend/static/index.html index 34168f4..e6747d5 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -624,12 +624,12 @@ - - - - - - + + + + + + @@ -639,7 +639,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index aa38bcc..c6129a0 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ 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 window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/landing.html b/backend/static/landing.html index 92c19ef..a75bb78 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index c398305..da22d33 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← 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_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/backend/weather.py b/backend/weather.py index f9a8ed2..918dab9 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -164,6 +164,88 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: _location_cache[key] = (now, 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` (0–23, 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 95–99 = Gewitter THUNDERSTORM_CODES = {95, 96, 99} diff --git a/tests/test_diary_location_enrich.py b/tests/test_diary_location_enrich.py new file mode 100644 index 0000000..4ea72c5 --- /dev/null +++ b/tests/test_diary_location_enrich.py @@ -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