banyaro/backend/routes/health.py
rene 6f48ec581d Backend Sprint 2+3: Health-Modul, Multi-Dog Tagebuch, Pillow, Migrations
- database.py: diary_dogs + walk_participant_dogs Tabellen, idempotente
  Migration für Health-Felder (charge_nr, kosten, diagnose, …), Backfill
- routes/health.py: vollständiges Health-Modul (war Stub), CRUD für
  Impfung/Entwurmung/Tierarzt/Medikament/Gewicht/Allergie/Dokument
- routes/diary.py: Multi-Dog n:m via diary_dogs (dog_ids in allen Endpoints)
- routes/dogs.py: Foto-Upload konvertiert HEIC/PNG/WebP → JPEG via Pillow
- routes/poison.py: Resolve mit Grundauswahl + Soft-Delete (geloest_von/at/grund)
- ki.py: health_summary() für KI-Gesundheitsbericht
- main.py: /favicon.ico Route
- requirements.txt: Pillow 11.2.1 + pillow-heif 0.22.0
2026-04-13 19:29:51 +02:00

283 lines
10 KiB
Python

"""BAN YARO — Gesundheit & Impfpass Routes"""
import os, uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# Erlaubte Typen
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class HealthCreate(BaseModel):
typ: str
bezeichnung: str
datum: str
naechstes: Optional[str] = None
notiz: Optional[str] = None
# Gewicht
wert: Optional[float] = None
einheit: Optional[str] = "kg"
# Impfung
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
# Tierarztbesuch
kosten: Optional[float] = None
diagnose: Optional[str] = None
# Medikament
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
aktiv: Optional[int] = 1
bis_datum: Optional[str] = None
# Allergie
schweregrad: Optional[str] = None # leicht | mittel | schwer
reaktion: Optional[str] = None
erinnerung: Optional[int] = 1
class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = None
datum: Optional[str] = None
naechstes: Optional[str] = None
notiz: Optional[str] = None
wert: Optional[float] = None
einheit: Optional[str] = None
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
kosten: Optional[float] = None
diagnose: Optional[str] = None
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
aktiv: Optional[int] = None
bis_datum: Optional[str] = None
schweregrad: Optional[str] = None
reaktion: Optional[str] = None
erinnerung: Optional[int] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Zugriffscheck Dog → User
# ------------------------------------------------------------------
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)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
# ------------------------------------------------------------------
# GET /api/dogs/{dog_id}/health
# ------------------------------------------------------------------
@router.get("/{dog_id}/health")
async def list_health(dog_id: int, typ: Optional[str] = None,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
if typ:
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? AND typ=? ORDER BY datum DESC",
(dog_id, typ)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health
# ------------------------------------------------------------------
@router.post("/{dog_id}/health", status_code=201)
async def create_health(dog_id: int, data: HealthCreate,
user=Depends(get_current_user)):
if data.typ not in TYPEN:
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(TYPEN)}")
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
conn.execute(
"""INSERT INTO health
(dog_id, typ, bezeichnung, datum, naechstes, notiz,
wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
dosierung, haeufigkeit, aktiv, bis_datum,
schweregrad, reaktion, erinnerung)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
data.notiz, data.wert, data.einheit, data.charge_nr,
data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
data.haeufigkeit, data.aktiv, data.bis_datum,
data.schweregrad, data.reaktion, data.erinnerung)
)
row = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PATCH /api/dogs/{dog_id}/health/{id}
# ------------------------------------------------------------------
@router.patch("/{dog_id}/health/{entry_id}")
async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {k: v for k, v in data.model_dump().items() if v is not None}
if not updates:
return dict(entry)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [entry_id]
conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/dogs/{dog_id}/health/{id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/health/{entry_id}", status_code=204)
async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)):
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)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
return None
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload
# ------------------------------------------------------------------
@router.post("/{dog_id}/health/{entry_id}/dokument")
async def upload_dokument(
dog_id: int,
entry_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
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)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in {".jpg", ".jpeg", ".png", ".pdf", ".webp"}:
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "health", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
datei_url = f"/media/health/{filename}"
datei_typ = "pdf" if ext == ".pdf" else "image"
with db() as conn:
conn.execute(
"UPDATE health SET datei_url=?, datei_typ=? WHERE id=?",
(datei_url, datei_typ, entry_id)
)
return {"datei_url": datei_url, "datei_typ": datei_typ}
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel):
symptoms: str
@router.post("/{dog_id}/health/symptom-check")
async def symptom_check(dog_id: int, data: SymptomCheckRequest,
user=Depends(get_current_user)):
from ki import symptom_check as ki_symptom_check, KIUnavailableError
with db() as conn:
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
dog_info = dict(dog)
if dog_info.get("geburtstag"):
try:
from datetime import date
geb = date.fromisoformat(dog_info["geburtstag"])
dog_info["alter_jahre"] = round((date.today() - geb).days / 365.25, 1)
except Exception:
dog_info["alter_jahre"] = "unbekannt"
try:
result = await ki_symptom_check(
symptoms=data.symptoms,
dog_info=dog_info,
user_is_premium=bool(user.get("is_premium")),
)
return result
except KIUnavailableError as e:
raise HTTPException(503, str(e))
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/ki-zusammenfassung
# ------------------------------------------------------------------
@router.post("/{dog_id}/health/ki-zusammenfassung")
async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
from ki import health_summary, KIUnavailableError, KIPremiumRequired
with db() as conn:
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog_id,)
).fetchall()
health_data = [dict(r) for r in rows]
try:
result = await health_summary(
health_data=health_data,
dog_info=dict(dog),
user_is_premium=bool(user.get("is_premium")),
)
return {"zusammenfassung": result}
except KIPremiumRequired as e:
raise HTTPException(402, str(e))
except KIUnavailableError as e:
raise HTTPException(503, str(e))