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
This commit is contained in:
rene 2026-04-13 19:29:51 +02:00
parent 96e7a97b52
commit 6f48ec581d
8 changed files with 570 additions and 52 deletions

View file

@ -13,20 +13,23 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute
typ: str = "eintrag"
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
is_milestone: bool = False
datum: Optional[str] = None # ISO date, default heute
typ: str = "eintrag"
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
class DiaryUpdate(BaseModel):
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
is_milestone: Optional[bool] = None
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
def _own_dog(dog_id: int, user_id: int, conn):
@ -38,23 +41,63 @@ def _own_dog(dog_id: int, user_id: int, conn):
return dog
def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
"""Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
all_ids = list({primary} | set(dog_ids))
for did in all_ids:
_own_dog(did, user_id, conn)
return all_ids
def _fetch_dog_ids(conn, entry_ids: list[int]) -> dict:
"""Gibt {entry_id: [dog_id, ...]} zurück."""
if not entry_ids:
return {}
ph = ",".join("?" * len(entry_ids))
rows = conn.execute(
f"SELECT diary_id, dog_id FROM diary_dogs WHERE diary_id IN ({ph})",
entry_ids
).fetchall()
result = {}
for r in rows:
result.setdefault(r["diary_id"], []).append(r["dog_id"])
return result
def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
conn.execute("DELETE FROM diary_dogs WHERE diary_id=?", (entry_id,))
for did in dog_ids:
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(entry_id, did)
)
def _entry_dict(row, dog_ids_map: dict) -> dict:
e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
return e
@router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
# Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist
rows = conn.execute(
"""SELECT * FROM diary WHERE dog_id=?
ORDER BY datum DESC, created_at DESC
"""SELECT DISTINCT d.* FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.dog_id = ? OR dd.dog_id = ?
ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""",
(dog_id, limit, offset)
(dog_id, dog_id, limit, offset)
).fetchall()
entries = []
for r in rows:
e = dict(r)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
entries.append(e)
return entries
ids = [r["id"] for r in rows]
dogs_map = _fetch_dog_ids(conn, ids)
return [_entry_dict(r, dogs_map) for r in rows]
@router.post("/{dog_id}/diary", status_code=201)
@ -72,6 +115,8 @@ async def create_diary(dog_id: int, data: DiaryCreate,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
@ -85,10 +130,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,)
).fetchone()
_set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
e = dict(entry)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e
return _entry_dict(entry, dogs_map)
@router.get("/{dog_id}/diary/{entry_id}")
@ -96,13 +141,16 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
"""SELECT DISTINCT d.* FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
dogs_map = _fetch_dog_ids(conn, [entry_id])
return _entry_dict(row, dogs_map)
@router.patch("/{dog_id}/diary/{entry_id}")
@ -110,20 +158,43 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
fields = {k: v for k, v in data.model_dump().items() if v is not None}
# Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
exists = conn.execute(
"""SELECT 1 FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone()
if not exists:
raise HTTPException(404, "Eintrag nicht gefunden.")
# Felder updaten
fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items()
if v is not None}
if "tags" in fields:
fields["tags"] = json.dumps(fields["tags"])
if not fields:
raise HTTPException(400, "Keine Änderungen.")
set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute(
f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?",
list(fields.values()) + [entry_id, dog_id]
)
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e
if fields:
# primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute(
f"UPDATE diary SET {set_clause} WHERE id=?",
list(fields.values()) + [entry_id]
)
# Hunde-Zuweisung aktualisieren
if data.dog_ids is not None:
# primary dog des Eintrags ermitteln
primary = conn.execute(
"SELECT dog_id FROM diary WHERE id=?", (entry_id,)
).fetchone()["dog_id"]
all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
_set_dog_ids(conn, entry_id, all_dogs)
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
dogs_map = _fetch_dog_ids(conn, [entry_id])
return _entry_dict(row, dogs_map)
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@ -142,7 +213,10 @@ async def upload_media(dog_id: int, entry_id: int,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
entry = conn.execute(
"SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
"""SELECT d.id FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")

View file

@ -113,13 +113,28 @@ async def upload_photo(
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
# Datei speichern
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}"
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
import io
from PIL import Image
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
content = await file.read()
try:
img = Image.open(io.BytesIO(content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
content = buf.getvalue()
except Exception:
pass # Fallback: Originaldaten speichern
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg"
path = os.path.join(MEDIA_DIR, "dogs", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
content = await file.read()
with open(path, "wb") as f:
f.write(content)

View file

@ -1,3 +1,283 @@
"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
from fastapi import APIRouter
router = APIRouter()
"""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))

View file

@ -35,6 +35,10 @@ class PoisonCreate(BaseModel):
typ: str = "unbekannt"
class PoisonResolve(BaseModel):
grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
# ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------
@ -114,7 +118,11 @@ async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
# Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------
@router.post("/{poison_id}/resolve")
async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
async def resolve_poison(
poison_id: int,
data: PoisonResolve = PoisonResolve(),
user=Depends(get_current_user)
):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
@ -124,7 +132,16 @@ async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,))
# Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
conn.execute(
"""UPDATE poison
SET geloest=1,
geloest_von=?,
geloest_at=datetime('now'),
geloest_grund=?
WHERE id=?""",
(user["id"], data.grund, poison_id)
)
return {"ok": True}