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:
parent
e2219fb8ba
commit
140140f690
8 changed files with 244 additions and 17 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- 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 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1304">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1304">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1304">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1304">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1304">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1305">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1305">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1305">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1305">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1305">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -624,12 +624,12 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1304"></script>
|
||||
<script src="/js/ui.js?v=1304"></script>
|
||||
<script src="/js/app.js?v=1304"></script>
|
||||
<script src="/js/worlds.js?v=1304"></script>
|
||||
<script src="/js/offline-indicator.js?v=1304"></script>
|
||||
<script src="/js/contact-form.js?v=1304"></script>
|
||||
<script src="/js/api.js?v=1305"></script>
|
||||
<script src="/js/ui.js?v=1305"></script>
|
||||
<script src="/js/app.js?v=1305"></script>
|
||||
<script src="/js/worlds.js?v=1305"></script>
|
||||
<script src="/js/offline-indicator.js?v=1305"></script>
|
||||
<script src="/js/contact-form.js?v=1305"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -639,7 +639,7 @@
|
|||
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue