Sprint 15: Zeitzone-Fix, Gewichts-Sync, Öffnungszeiten, KI-Bericht, POI-Moderation — SW by-v432, APP_VER 411

- 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
This commit is contained in:
rene 2026-04-26 15:38:50 +02:00
parent 679dbdd862
commit 06bd8525ed
21 changed files with 724 additions and 75 deletions

View file

@ -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"
"MoFr 08:0018:00 · Sa 09:0013: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",