From 06bd8525ed7102527d96de588670d2ab8ac546cb Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 26 Apr 2026 15:38:50 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=2015:=20Zeitzone-Fix,=20Gewichts-Sync,?= =?UTF-8?q?=20=C3=96ffnungszeiten,=20KI-Bericht,=20POI-Moderation=20?= =?UTF-8?q?=E2=80=94=20SW=20by-v432,=20APP=5FVER=20411?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_time: Browser-Lokalzeit bei allen Creates mitschicken (Tagebuch, Notizen, Forum, Verlorener Hund, Routen) — kein UTC-Versatz mehr bei Einträgen - Gewicht-Sync: health typ=gewicht schreibt dogs.gewicht_kg, einmalige Migration - Praxen: opening_hours + lat/lon/osm_id in tieraerzte-Tabelle, OSM-Nearby-Lookup, Öffnungszeiten in Karte und Detailansicht - KI-Gesundheitsbericht: alle 2 Wochen automatisch, ki_health_reports-Tabelle, Frontend-Banner mit Archiv (letzten 5 Berichte) - POI-Korrekturen: User schlägt Öffnungszeiten-Änderung vor, Moderatoren-Tab genehmigt/lehnt ab, user_edited-Flag schützt vor Overpass-Überschreibung - timeutils.py: safe_client_time() zentral für alle Routen --- backend/database.py | 59 +++++++- backend/routes/diary.py | 14 +- backend/routes/forum.py | 29 ++-- backend/routes/health.py | 36 ++++- backend/routes/lost.py | 9 +- backend/routes/moderation.py | 70 +++++++++- backend/routes/notes.py | 4 +- backend/routes/osm.py | 43 +++++- backend/routes/routen.py | 9 +- backend/routes/tieraerzte.py | 122 ++++++++++++---- backend/scheduler.py | 79 +++++++++++ backend/static/js/api.js | 15 +- backend/static/js/app.js | 2 +- backend/static/js/pages/diary.js | 3 +- backend/static/js/pages/forum.js | 15 +- backend/static/js/pages/health.js | 192 +++++++++++++++++++++++++- backend/static/js/pages/lost.js | 1 + backend/static/js/pages/moderation.js | 76 ++++++++++ backend/static/js/pages/routes.js | 4 +- backend/static/sw.js | 2 +- backend/timeutils.py | 15 ++ 21 files changed, 724 insertions(+), 75 deletions(-) create mode 100644 backend/timeutils.py diff --git a/backend/database.py b/backend/database.py index e4c43ed..6214ec9 100644 --- a/backend/database.py +++ b/backend/database.py @@ -440,9 +440,13 @@ def _migrate(conn_factory): ("health", "datei_typ", "TEXT"), ("health", "tierarzt_id", "INTEGER"), # Tierärzte: Adresse aufgeteilt in Strasse/PLZ/Ort - ("tieraerzte", "strasse", "TEXT"), - ("tieraerzte", "plz", "TEXT"), - ("tieraerzte", "ort", "TEXT"), + ("tieraerzte", "strasse", "TEXT"), + ("tieraerzte", "plz", "TEXT"), + ("tieraerzte", "ort", "TEXT"), + ("tieraerzte", "opening_hours", "TEXT"), + ("tieraerzte", "lat", "REAL"), + ("tieraerzte", "lon", "REAL"), + ("tieraerzte", "osm_id", "TEXT"), # Gesundheit: Erinnerungsintervall für wiederkehrende Einträge ("health", "intervall_tage", "INTEGER"), # Routen: neue Felder @@ -453,6 +457,7 @@ def _migrate(conn_factory): ("osm_pois", "opening_hours", "TEXT"), ("osm_pois", "phone", "TEXT"), ("osm_pois", "website", "TEXT"), + ("osm_pois", "user_edited", "INTEGER NOT NULL DEFAULT 0"), # Forum: Threads brauchen text + antworten-Zähler ("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"), ("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"), @@ -1157,3 +1162,51 @@ def _migrate(conn_factory): ON notes(user_id, created_at DESC); """) logger.info("Migration: notes Tabelle bereit.") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_health_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bericht TEXT NOT NULL, + erstellt_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog + ON ki_health_reports(dog_id, erstellt_at DESC) + """) + logger.info("Migration: ki_health_reports Tabelle bereit.") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS osm_poi_edits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + osm_id TEXT NOT NULL, + poi_name TEXT NOT NULL, + field TEXT NOT NULL DEFAULT 'opening_hours', + old_value TEXT, + new_value TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + mod_id INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_osm_poi_edits_status + ON osm_poi_edits(status, created_at DESC); + """) + logger.info("Migration: osm_poi_edits Tabelle bereit.") + + # Einmalige Datenmigration: dogs.gewicht_kg mit aktuellem Gesundheits-Gewicht synchronisieren + conn.execute(""" + UPDATE dogs SET gewicht_kg = ( + SELECT wert FROM health + WHERE health.dog_id = dogs.id AND health.typ = 'gewicht' AND health.wert IS NOT NULL + ORDER BY health.datum DESC, health.id DESC LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 FROM health + WHERE health.dog_id = dogs.id AND health.typ = 'gewicht' AND health.wert IS NOT NULL + ) + """) + logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.") diff --git a/backend/routes/diary.py b/backend/routes/diary.py index abd2b35..888f953 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -10,6 +10,7 @@ import ki as KI import httpx import weather as weather_mod from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif +from timeutils import safe_client_time logger = logging.getLogger(__name__) @@ -19,6 +20,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): datum: Optional[str] = None # ISO date, default heute + client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) typ: str = "eintrag" titel: Optional[str] = None text: Optional[str] = None @@ -288,14 +290,14 @@ async def create_diary(dog_id: int, data: DiaryCreate, else: all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn) + ct = safe_client_time(data.client_time) + datum = data.datum or ct[:10] conn.execute( """INSERT INTO diary - (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone) - VALUES (?, - COALESCE(?, date('now')), - ?,?,?,?,?,?,?,?)""", - (dog_id, data.datum, data.typ, data.titel, data.text, - json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone)) + (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + (dog_id, datum, data.typ, data.titel, data.text, + json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone), ct) ) entry = conn.execute( "SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/routes/forum.py b/backend/routes/forum.py index a7ce455..ccf610d 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user, get_current_user_optional +from timeutils import safe_client_time from routes.push import send_push_to_user from media_utils import convert_media, extract_video_thumb @@ -24,15 +25,17 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', # Schemas # ------------------------------------------------------------------ class ThreadCreate(BaseModel): - kategorie: str = 'allgemein' - titel: str - text: str - thread_lat: Optional[float] = None - thread_lon: Optional[float] = None - thread_ort: Optional[str] = None + kategorie: str = 'allgemein' + titel: str + text: str + thread_lat: Optional[float] = None + thread_lon: Optional[float] = None + thread_ort: Optional[str] = None + client_time: Optional[str] = None class PostCreate(BaseModel): - text: str + text: str + client_time: Optional[str] = None class ThreadPatch(BaseModel): is_pinned: Optional[int] = None @@ -165,11 +168,12 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if data.kategorie not in KATEGORIEN: raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: + ct = safe_client_time(data.client_time) cur = conn.execute( - """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort) - VALUES (?, ?, ?, ?, ?, ?, ?)""", + """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (user['id'], data.kategorie, data.titel.strip(), data.text.strip(), - data.thread_lat, data.thread_lon, data.thread_ort) + data.thread_lat, data.thread_lon, data.thread_ort, ct) ) row = conn.execute( """SELECT t.*, u.name AS autor_name @@ -307,9 +311,10 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") + ct = safe_client_time(data.client_time) cur = conn.execute( - "INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)", - (thread_id, user['id'], data.text.strip()) + "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)", + (thread_id, user['id'], data.text.strip(), ct) ) conn.execute( "UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?", diff --git a/backend/routes/health.py b/backend/routes/health.py index 2ae86ea..bbdb1ee 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -75,6 +75,18 @@ class HealthUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ +def _sync_gewicht(conn, dog_id: int): + """Aktualisiert dogs.gewicht_kg auf den neuesten Gewichtseintrag (nach datum).""" + conn.execute( + """UPDATE dogs SET gewicht_kg = ( + SELECT wert FROM health + WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL + ORDER BY datum DESC, id DESC LIMIT 1 + ) WHERE id=?""", + (dog_id, dog_id) + ) + + def _check_dog_owner(conn, dog_id: int, user_id: int): dog = conn.execute( "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) @@ -160,6 +172,8 @@ async def create_health(dog_id: int, data: HealthCreate, (dog_id,) ).fetchone() media_map = _fetch_media_items(conn, [row["id"]]) + if data.typ == 'gewicht': + _sync_gewicht(conn, dog_id) return _entry_with_media(row, media_map) @@ -186,6 +200,8 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate, conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values) row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone() media_map = _fetch_media_items(conn, [entry_id]) + if row["typ"] == 'gewicht': + _sync_gewicht(conn, dog_id) return _entry_with_media(row, media_map) @@ -197,11 +213,14 @@ async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_use with db() as conn: _check_dog_owner(conn, dog_id, user["id"]) entry = conn.execute( - "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + "SELECT id, typ FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) ).fetchone() if not entry: raise HTTPException(404, "Eintrag nicht gefunden.") + was_gewicht = entry["typ"] == 'gewicht' conn.execute("DELETE FROM health WHERE id=?", (entry_id,)) + if was_gewicht: + _sync_gewicht(conn, dog_id) return None @@ -431,3 +450,18 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)): raise HTTPException(402, str(e)) except KIUnavailableError as e: raise HTTPException(503, str(e)) + + +# ------------------------------------------------------------------ +# GET /api/dogs/{dog_id}/health/ki-berichte +# ------------------------------------------------------------------ +@router.get("/{dog_id}/health/ki-berichte") +async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + rows = conn.execute( + """SELECT id, bericht, erstellt_at FROM ki_health_reports + WHERE dog_id=? ORDER BY erstellt_at DESC LIMIT 5""", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/routes/lost.py b/backend/routes/lost.py index 39c50ad..3b02ed3 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from timeutils import safe_client_time from routes.push import send_push_to_all from media_utils import convert_media @@ -37,6 +38,7 @@ class LostDogCreate(BaseModel): lat: float lon: float dog_id: Optional[int] = None + client_time: Optional[str] = None # ------------------------------------------------------------------ @@ -76,11 +78,12 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None, @router.post("", status_code=201) async def report_lost(data: LostDogCreate, user=Depends(get_current_user)): with db() as conn: + ct = safe_client_time(data.client_time) conn.execute( - """INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon) - VALUES (?, ?, ?, ?, ?, ?, ?)""", + """INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (user["id"], data.dog_id, data.name, data.rasse, - data.beschreibung, data.lat, data.lon) + data.beschreibung, data.lat, data.lon, ct) ) row = conn.execute( "SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 9c00d6f..95b33a9 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -45,11 +45,20 @@ async def mod_stats(user=Depends(require_moderator)): except Exception: pass + pending_poi_edits = 0 + try: + pending_poi_edits = conn.execute( + "SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'" + ).fetchone()[0] + except Exception: + pass + return { - "open_reports": open_reports, - "pending_fotos": pending_fotos, - "banned_users": banned_users, - "pending_zuchter": pending_zuchter, + "open_reports": open_reports, + "pending_fotos": pending_fotos, + "banned_users": banned_users, + "pending_zuchter": pending_zuchter, + "pending_poi_edits": pending_poi_edits, } @@ -207,3 +216,56 @@ async def mod_foto_action(foto_id: int, data: dict, user=Depends(require_moderat reject_reason=data.get("reject_reason", ""), ) return await review_submission(foto_id, model, user) + + +# ------------------------------------------------------------------ +# GET /api/moderation/poi-edits — ausstehende POI-Korrekturen +# ------------------------------------------------------------------ +@router.get("/poi-edits") +async def mod_poi_edits(user=Depends(require_moderator)): + with db() as conn: + rows = conn.execute(""" + SELECT e.id, e.osm_id, e.poi_name, e.field, + e.old_value, e.new_value, e.status, + e.created_at, e.resolved_at, + u.name AS einreicher_name + FROM osm_poi_edits e + JOIN users u ON u.id = e.user_id + ORDER BY e.status ASC, e.created_at DESC + LIMIT 100 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/moderation/poi-edits/{id} — approve / reject +# ------------------------------------------------------------------ +@router.patch("/poi-edits/{edit_id}") +async def mod_poi_edit_action(edit_id: int, data: dict, + user=Depends(require_moderator)): + action = data.get("action") + if action not in ("approve", "reject"): + raise HTTPException(400, "action muss 'approve' oder 'reject' sein.") + + with db() as conn: + edit = conn.execute( + "SELECT * FROM osm_poi_edits WHERE id=?", (edit_id,) + ).fetchone() + if not edit: + raise HTTPException(404, "Korrektur nicht gefunden.") + if edit["status"] != "pending": + raise HTTPException(409, "Korrektur wurde bereits bearbeitet.") + + if action == "approve": + conn.execute( + f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?", + (edit["new_value"], edit["osm_id"]) + ) + + conn.execute( + """UPDATE osm_poi_edits SET status=?, mod_id=?, resolved_at=datetime('now') + WHERE id=?""", + (action + "d", user["id"], edit_id) + ) + + return {"status": action + "d"} diff --git a/backend/routes/notes.py b/backend/routes/notes.py index a85a2a2..1c6527b 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from typing import Optional, Any, List from database import db from auth import get_current_user +from timeutils import safe_client_time router = APIRouter() logger = logging.getLogger(__name__) @@ -21,6 +22,7 @@ class NoteCreate(BaseModel): meta_json: Optional[Any] = None location_name: Optional[str] = None parent_label: Optional[str] = None + client_time: Optional[str] = None class NoteUpdate(BaseModel): @@ -194,7 +196,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate, raise HTTPException(400, "Notiz darf nicht leer sein.") meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None - now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + now = safe_client_time(data.client_time) with db() as conn: conn.execute( diff --git a/backend/routes/osm.py b/backend/routes/osm.py index d6e0df0..d75631b 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -156,7 +156,7 @@ async def _fetch_and_store_tile(poi_type, x, y): ON CONFLICT(osm_id, type) DO UPDATE SET lat=excluded.lat, lon=excluded.lon, name=excluded.name, - opening_hours=excluded.opening_hours, + opening_hours=CASE WHEN user_edited=1 THEN opening_hours ELSE excluded.opening_hours END, phone=excluded.phone, website=excluded.website, cached_at=excluded.cached_at @@ -372,3 +372,44 @@ async def analyze_region( background_tasks.add_task(_warmup) return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} + + +# ------------------------------------------------------------------ +# POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor +# ------------------------------------------------------------------ +class PoiEditCreate(BaseModel): + poi_name: str + field: str = 'opening_hours' + new_value: str + + +@router.post('/pois/{osm_id}/edit', status_code=201) +async def submit_poi_edit(osm_id: str, data: PoiEditCreate, + user=Depends(get_current_user)): + if data.field not in ('opening_hours',): + raise HTTPException(400, "Nur 'opening_hours' kann korrigiert werden.") + if not data.new_value.strip(): + raise HTTPException(400, "Neuer Wert darf nicht leer sein.") + + with db() as conn: + poi = conn.execute( + "SELECT name, opening_hours FROM osm_pois WHERE osm_id=?", (osm_id,) + ).fetchone() + if not poi: + raise HTTPException(404, "POI nicht gefunden.") + + existing = conn.execute( + """SELECT id FROM osm_poi_edits + WHERE osm_id=? AND field=? AND status='pending' AND user_id=?""", + (osm_id, data.field, user["id"]) + ).fetchone() + if existing: + raise HTTPException(409, "Du hast bereits eine ausstehende Korrektur für diesen POI.") + + conn.execute( + """INSERT INTO osm_poi_edits (osm_id, poi_name, field, old_value, new_value, user_id) + VALUES (?, ?, ?, ?, ?, ?)""", + (osm_id, data.poi_name or poi["name"], data.field, + poi[data.field], data.new_value.strip(), user["id"]) + ) + return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."} diff --git a/backend/routes/routen.py b/backend/routes/routen.py index f2a2efc..8205af7 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -8,6 +8,7 @@ from typing import Optional, List from database import db from auth import get_current_user, get_current_user_optional from routes.achievements import update_streak, check_and_award +from timeutils import safe_client_time from media_utils import convert_media from routes.push import send_push_to_user @@ -52,6 +53,7 @@ class RouteCreate(BaseModel): leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = False hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium + client_time: Optional[str] = None class RouteUpdate(BaseModel): name: Optional[str] = None @@ -146,20 +148,21 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): gps_json = json.dumps([p.model_dump() for p in data.gps_track]) is_valid = int(_check_speed(data.distanz_km, data.dauer_min)) + ct = safe_client_time(data.client_time) with db() as conn: cur = conn.execute(""" INSERT INTO routes (user_id, name, beschreibung, gps_track, distanz_km, dauer_min, schwierigkeit, untergrund, schatten, leine_empfohlen, is_public, - hunde_tauglichkeit, is_valid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + hunde_tauglichkeit, is_valid, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user['id'], data.name, data.beschreibung, gps_json, data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund, int(data.schatten) if data.schatten is not None else None, int(data.leine_empfohlen) if data.leine_empfohlen is not None else None, int(data.is_public) if data.is_public is not None else 1, - data.hunde_tauglichkeit, is_valid, + data.hunde_tauglichkeit, is_valid, ct, )) row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() update_streak(user['id'], conn) diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 616ea90..55107ec 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -1,6 +1,7 @@ """BAN YARO — Tierärzte Routes (user-level, nie löschen)""" -from fastapi import APIRouter, Depends, HTTPException +import math +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional from database import db @@ -11,34 +12,60 @@ router = APIRouter() class TierarztCreate(BaseModel): name: str - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None - ist_notfallpraxis: bool = False + strasse: Optional[str] = None + plz: Optional[str] = None + ort: Optional[str] = None + telefon: Optional[str] = None + notfall_telefon: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None + notizen: Optional[str] = None + ist_notfallpraxis: bool = False + opening_hours: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + osm_id: Optional[str] = None class TierarztUpdate(BaseModel): - name: Optional[str] = None - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None + name: Optional[str] = None + strasse: Optional[str] = None + plz: Optional[str] = None + ort: Optional[str] = None + telefon: Optional[str] = None + notfall_telefon: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None + notizen: Optional[str] = None ist_notfallpraxis: Optional[bool] = None - aktiv: Optional[bool] = None # False = inaktiv (Umzug etc.) + aktiv: Optional[bool] = None + opening_hours: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + osm_id: Optional[str] = None + + +def _fmt_opening_hours(raw: str | None) -> str | None: + """Wandelt OSM-opening_hours-String in lesbares Deutsch um. + + Beispiel: "Mo-Fr 08:00-18:00; Sa 09:00-13:00" + → "Mo–Fr 08:00–18:00 · Sa 09:00–13:00" + """ + if not raw: + return None + if raw.strip().lower() == "24/7": + return "24/7 geöffnet" + result = raw.replace(" - ", "–").replace("-", "–", 1) + result = "; ".join( + part.strip().replace(";", "").replace(",", " · ") + for part in raw.split(";") + ) + return result @router.get("") async def list_tieraerzte(user=Depends(get_current_user)): - """Alle Tierärzte des Users — aktive zuerst, dann inaktive (für Historienansicht).""" + """Alle Tierärzte des Users — aktive zuerst, dann inaktive.""" with db() as conn: rows = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", @@ -47,17 +74,64 @@ async def list_tieraerzte(user=Depends(get_current_user)): return [dict(r) for r in rows] +@router.get("/osm-nearby") +async def osm_nearby( + lat: float = Query(...), + lon: float = Query(...), + radius_km: float = Query(5.0), + user=Depends(get_current_user) +): + """Findet Tierarzt-POIs aus dem OSM-Cache in der Nähe (max. 10 Treffer).""" + with db() as conn: + rows = conn.execute( + """SELECT osm_id, name, lat, lon, opening_hours, phone, website + FROM osm_pois + WHERE type = 'tierarzt' + AND lat BETWEEN ? AND ? + AND lon BETWEEN ? AND ?""", + (lat - radius_km / 111.0, lat + radius_km / 111.0, + lon - radius_km / 111.0, lon + radius_km / 111.0) + ).fetchall() + + def _dist(r): + dlat = (r["lat"] - lat) * math.pi / 180 + dlon = (r["lon"] - lon) * math.pi / 180 + a = math.sin(dlat/2)**2 + math.cos(lat*math.pi/180) * math.cos(r["lat"]*math.pi/180) * math.sin(dlon/2)**2 + return 6371 * 2 * math.asin(math.sqrt(a)) + + results = [] + for r in rows: + d = _dist(r) + if d <= radius_km: + results.append({ + "osm_id": r["osm_id"], + "name": r["name"], + "lat": r["lat"], + "lon": r["lon"], + "opening_hours": r["opening_hours"], + "opening_hours_fmt": _fmt_opening_hours(r["opening_hours"]), + "phone": r["phone"], + "website": r["website"], + "distanz_km": round(d, 2), + }) + + results.sort(key=lambda x: x["distanz_km"]) + return results[:10] + + @router.post("", status_code=201) async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)): with db() as conn: conn.execute( """INSERT INTO tieraerzte (user_id, name, strasse, plz, ort, telefon, notfall_telefon, - email, website, notizen, ist_notfallpraxis) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + email, website, notizen, ist_notfallpraxis, + opening_hours, lat, lon, osm_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (user["id"], data.name, data.strasse, data.plz, data.ort, data.telefon, data.notfall_telefon, data.email, data.website, - data.notizen, int(data.ist_notfallpraxis)) + data.notizen, int(data.ist_notfallpraxis), + data.opening_hours, data.lat, data.lon, data.osm_id) ) row = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/scheduler.py b/backend/scheduler.py index c0c2090..9eeecab 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,6 +100,14 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) + _scheduler.add_job( + _job_ki_health_report, + CronTrigger(day_of_week='mon', hour=7, minute=0), + id="ki_health_report", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start. OSM-Cache: on-demand (kein Prewarm).") @@ -745,6 +753,76 @@ async def _job_weekly_praise(): _log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}") +# ------------------------------------------------------------------ +# JOB: KI-Gesundheitsberichte (alle 2 Wochen, jeden Montag 07:00) +# ------------------------------------------------------------------ +async def _job_ki_health_report(): + """ + Erstellt für jeden Hund, der seit mehr als 13 Tagen keinen KI-Gesundheitsbericht + hat (oder noch keinen hatte), einen neuen Bericht via ki.health_summary() und + schickt eine Push-Notification an den Besitzer. Maximal 20 Hunde pro Lauf. + """ + import ki as KI + + with db() as conn: + dogs = conn.execute(""" + SELECT d.id AS dog_id, d.name, d.rasse, d.geburtstag, d.gewicht_kg, d.user_id + FROM dogs d + WHERE d.id NOT IN ( + SELECT dog_id FROM ki_health_reports + WHERE erstellt_at >= datetime('now', '-13 days') + ) + ORDER BY d.id + LIMIT 20 + """).fetchall() + + dogs = [dict(d) for d in dogs] + if not dogs: + logger.info("KI-Gesundheitsbericht: Keine fälligen Hunde.") + _log_job("ki_health_report", "ok", "0 Berichte erstellt") + return + + count = 0 + for dog in dogs: + try: + with db() as conn: + health_rows = conn.execute( + "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", + (dog["dog_id"],) + ).fetchall() + health_data = [dict(r) for r in health_rows] + + dog_info = { + "name": dog["name"], + "rasse": dog.get("rasse"), + "geburtstag": dog.get("geburtstag"), + "gewicht_kg": dog.get("gewicht_kg"), + } + + bericht = await KI.health_summary(health_data=health_data, dog_info=dog_info) + + with db() as conn: + conn.execute( + "INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?, ?, ?)", + (dog["dog_id"], dog["user_id"], bericht) + ) + + send_push_to_user(dog["user_id"], { + "type": "ki_health_report", + "title": f"Gesundheitsbericht für {dog['name']}", + "body": "Dein KI-Assistent hat einen neuen Bericht erstellt.", + "data": {"page": "health"}, + }) + + count += 1 + logger.info(f"KI-Gesundheitsbericht: Bericht für Hund {dog['dog_id']} ({dog['name']}) erstellt.") + except Exception as e: + logger.error(f"KI-Gesundheitsbericht: Fehler für Hund {dog['dog_id']} ({dog['name']}): {e}") + + logger.info(f"KI-Gesundheitsbericht Job fertig — {count}/{len(dogs)} Berichte erstellt.") + _log_job("ki_health_report", "ok", f"{count} Berichte erstellt") + + # ------------------------------------------------------------------ # JOB: Status-Report per Mail (4× täglich) # ------------------------------------------------------------------ @@ -801,6 +879,7 @@ async def _job_status_report(): "seed_breeds_startup": "Rassen-Seed (TheDogAPI)", "seed_wikidata_startup":"Rassen-Seed (Wikidata)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", + "ki_health_report": "KI-Gesundheitsberichte", } job_rows_html = "" job_rows_txt = "" diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 1e828e9..39c423e 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -168,6 +168,7 @@ const API = (() => { kiZusammenfassung(dogId) { return post(`/dogs/${dogId}/health/ki-zusammenfassung`); }, + kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); }, symptomCheck(dogId, symptoms) { return post(`/dogs/${dogId}/health/symptom-check`, { symptoms }); }, @@ -180,9 +181,10 @@ const API = (() => { // TIERÄRZTE // ---------------------------------------------------------- const tieraerzte = { - list() { return get('/tieraerzte'); }, - create(data) { return post('/tieraerzte', data); }, - update(id, d) { return patch(`/tieraerzte/${id}`, d); }, + list() { return get('/tieraerzte'); }, + create(data) { return post('/tieraerzte', data); }, + update(id, d) { return patch(`/tieraerzte/${id}`, d); }, + osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- @@ -598,13 +600,18 @@ const API = (() => { } } + // Lokale Gerätezeit als ISO-String ("2026-04-26T12:00:00") für server-seitige created_at + function clientNow() { + return new Date().toLocaleString('sv').replace(' ', 'T'); + } + // Öffentliche API return { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, - subscribeToPush, getLocation, + subscribeToPush, getLocation, clientNow, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 245bbf3..f1c0a17 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 = '408'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '411'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index b54a42e..c3ddc69 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1732,6 +1732,7 @@ window.Page_diary = (() => { gps_lat: _locLat, gps_lon: _locLon, location_name: _locName, + client_time: API.clientNow(), }; async function _uploadNewFiles(entryId) { @@ -2012,7 +2013,7 @@ window.Page_diary = (() => { const text = textarea.value.trim(); UI.setLoading(saveBtn, true); try { - const payload = { text, parent_label: parentLabel, location_name: locationName }; + const payload = { text, parent_label: parentLabel, location_name: locationName, client_time: API.clientNow() }; if (existingNoteId) { await API.notes.update(existingNoteId, payload); } else { diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 2399f62..f1ddfe7 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -531,7 +531,7 @@ window.Page_forum = (() => { if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; } await UI.asyncButton(btn, async () => { - const post = await API.forum.addPost(thread.id, { text }); + const post = await API.forum.addPost(thread.id, { text, client_time: API.clientNow() }); // Foto hochladen falls vorhanden const files = Array.from(document.getElementById('forum-reply-file')?.files || []); @@ -900,12 +900,13 @@ window.Page_forum = (() => { const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null }; const created = await API.forum.create({ - kategorie: fd.kategorie, - titel: (fd.titel || '').trim(), - text: (fd.text || '').trim(), - thread_lat: loc.lat ?? null, - thread_lon: loc.lon ?? null, - thread_ort: loc.name ?? null, + kategorie: fd.kategorie, + titel: (fd.titel || '').trim(), + text: (fd.text || '').trim(), + thread_lat: loc.lat ?? null, + thread_lon: loc.lon ?? null, + thread_ort: loc.name ?? null, + client_time: API.clientNow(), }); // Fotos hochladen diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 48ce1e8..aef0c03 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -152,6 +152,7 @@ window.Page_health = (() => { ${transponderHtml} +
@@ -166,6 +167,7 @@ window.Page_health = (() => { await _loadAll(); _renderErinnerungen(); _renderTab(); + _loadKiBerichte(dog.id); } // ---------------------------------------------------------- @@ -1009,7 +1011,8 @@ window.Page_health = (() => { if (praxis) { const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', '); const tel = praxis.telefon ? ` · ${_esc(praxis.telefon)}` : ''; - rows.push(['Praxis', ` ${_esc(praxis.name)}${adresse ? `
${_esc(adresse)}${tel}` : tel}`]); + const oh = praxis.opening_hours ? `
🕐 ${_esc(_fmtOeffnungszeiten(praxis.opening_hours))}` : ''; + rows.push(['Praxis', ` ${_esc(praxis.name)}${adresse ? `
${_esc(adresse)}${tel}` : tel}${oh}`]); } } else if (e.tierarzt_name) { rows.push(['Tierarzt', _esc(e.tierarzt_name)]); @@ -1561,6 +1564,11 @@ window.Page_health = (() => {
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
` : ''} + ${p.opening_hours ? ` +
+ + ${_esc(_fmtOeffnungszeiten(p.opening_hours))} +
` : ''}
${p.telefon ? ` {
+
+ + + +
+ placeholder="Besonderheiten, interne Hinweise…">${_esc(praxis?.notizen || '')}
@@ -456,6 +462,76 @@ window.Page_moderation = (() => { `; } + // ------------------------------------------------------------------ + // TAB: POI-KORREKTUREN + // ------------------------------------------------------------------ + async function _renderPoiEdits(el) { + const edits = await API.get('/moderation/poi-edits'); + if (!edits.length) { + el.innerHTML = _emptyState('check-circle', 'Alles erledigt', 'Keine ausstehenden POI-Korrekturen.'); + return; + } + + const STATUS_LABEL = { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' }; + const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' }; + + el.innerHTML = ` +
+ ${edits.map(e => ` +
+
+
+
${_esc(e.poi_name)}
+
+ OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)} + · ${new Date(e.created_at).toLocaleDateString('de-DE')} +
+
+ + ${STATUS_LABEL[e.status] || e.status} + +
+
+
+
Aktuell
+
${_esc(e.old_value) || 'leer'}
+
+
+
Vorschlag
+
${_esc(e.new_value)}
+
+
+ ${e.status === 'pending' ? ` +
+ + +
` : ''} +
+ `).join('')} +
+ `; + + el.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const action = btn.dataset.action; + btn.disabled = true; + try { + await API.patch(`/moderation/poi-edits/${id}`, { action }); + UI.toast.success(action === 'approve' ? 'Übernommen!' : 'Abgelehnt.'); + await _renderPoiEdits(el); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + btn.disabled = false; + } + }); + }); + } + function _esc(s) { if (!s) return ''; return String(s) diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index f3296b1..b3591d9 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -630,6 +630,7 @@ window.Page_routes = (() => { leine_empfohlen: 'leine_empfohlen' in fd, is_public: 'is_public' in fd, hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', + client_time: API.clientNow(), }); UI.modal.close(); UI.toast.success(`Route „${saved.name}" gespeichert!`); @@ -2428,6 +2429,7 @@ window.Page_routes = (() => { leine_empfohlen: document.getElementById('ri-leine')?.checked, is_public: document.getElementById('ri-public')?.checked, hunde_tauglichkeit: _selPaw, + client_time: API.clientNow(), }); UI.modal.close(); UI.toast.success('Route importiert! 🥾'); @@ -2551,7 +2553,7 @@ window.Page_routes = (() => { ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => { const text = ovl.querySelector('#rk-note-text')?.value?.trim() || ''; - const payload = { text, parent_label: parentLabel, location_name: locationName || null }; + const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() }; try { if (existingNote?.id) { await API.notes.update(existingNote.id, payload); diff --git a/backend/static/sw.js b/backend/static/sw.js index b23f40a..57483ff 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v427'; +const CACHE_VERSION = 'by-v432'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/backend/timeutils.py b/backend/timeutils.py new file mode 100644 index 0000000..9c1821b --- /dev/null +++ b/backend/timeutils.py @@ -0,0 +1,15 @@ +"""Hilfsfunktionen für client-seitige Zeitstempel.""" +import re +from datetime import datetime + + +def safe_client_time(client_time: str | None) -> str: + """Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now. + + Schützt gegen Injection: nur YYYY-MM-DD HH:MM[:SS] erlaubt. + """ + if client_time and re.match( + r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time + ): + return client_time.replace('T', ' ')[:19] + return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")