From 6f48ec581dcfc995dbf666207fda237403ba9bcc Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 13 Apr 2026 19:29:51 +0200 Subject: [PATCH] Backend Sprint 2+3: Health-Modul, Multi-Dog Tagebuch, Pillow, Migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/database.py | 61 +++++++++ backend/ki.py | 65 +++++++++ backend/main.py | 4 + backend/requirements.txt | 2 + backend/routes/diary.py | 160 ++++++++++++++++------ backend/routes/dogs.py | 23 +++- backend/routes/health.py | 286 ++++++++++++++++++++++++++++++++++++++- backend/routes/poison.py | 21 ++- 8 files changed, 570 insertions(+), 52 deletions(-) diff --git a/backend/database.py b/backend/database.py index 209239f..d268de4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -88,6 +88,14 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC); + -- TAGEBUCH ↔ HUNDE (n:m — ein Eintrag kann mehrere Hunde betreffen) + CREATE TABLE IF NOT EXISTS diary_dogs ( + diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (diary_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id); + -- GESUNDHEIT CREATE TABLE IF NOT EXISTS health ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -186,6 +194,15 @@ def init_db(): PRIMARY KEY (walk_id, user_id) ); + -- GASSI-TREFFEN ↔ HUNDE (n:m — Teilnehmer kann mehrere Hunde mitbringen) + CREATE TABLE IF NOT EXISTS walk_participant_dogs ( + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (walk_id, user_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id); + -- FORUM CREATE TABLE IF NOT EXISTS forum_threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -234,4 +251,48 @@ def init_db(): """) + # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent) + _migrate(conn_factory=db) + logger.info("Datenbank initialisiert.") + + +def _migrate(conn_factory): + """Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent).""" + migrations = [ + # Giftköder: Auflösungs-Details für spätere KI-Analyse + ("poison", "geloest_von", "INTEGER"), + ("poison", "geloest_at", "TEXT"), + ("poison", "geloest_grund", "TEXT"), + # Gesundheit: erweiterte Felder je nach Eintragstyp + ("health", "charge_nr", "TEXT"), + ("health", "tierarzt_name", "TEXT"), + ("health", "kosten", "REAL"), + ("health", "diagnose", "TEXT"), + ("health", "dosierung", "TEXT"), + ("health", "haeufigkeit", "TEXT"), + ("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"), + ("health", "bis_datum", "TEXT"), + ("health", "schweregrad", "TEXT"), + ("health", "reaktion", "TEXT"), + ("health", "datei_url", "TEXT"), + ("health", "datei_typ", "TEXT"), + ] + with conn_factory() as conn: + for table, column, col_type in migrations: + existing = [ + row[1] for row in + conn.execute(f"PRAGMA table_info({table})").fetchall() + ] + if column not in existing: + conn.execute( + f"ALTER TABLE {table} ADD COLUMN {column} {col_type}" + ) + logger.info(f"Migration: {table}.{column} hinzugefügt.") + + # Datenmigration: diary_dogs für bestehende Einträge befüllen + conn.execute(""" + INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) + SELECT id, dog_id FROM diary + """) + logger.info("Migration: diary_dogs Backfill abgeschlossen.") diff --git a/backend/ki.py b/backend/ki.py index 6eb0f1f..c4f6ec2 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -289,6 +289,71 @@ Bewerte als JSON: return {"plausibel": True, "typ": "unbekannt", "hinweis": description} +async def health_summary(health_data: list, dog_info: dict, + user_is_premium: bool = False) -> str: + """ + Tierärztliche Zusammenfassung aller Gesundheitsdaten — lokal für alle. + Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen. + """ + system = ( + "Du bist ein erfahrener Veterinär-Assistent. " + "Erstelle präzise, strukturierte Gesundheitsberichte für Hunde auf Deutsch. " + "Weise auf fällige Impfungen und wichtige Termine hin." + ) + + # Daten nach Typ gruppieren für den Prompt + def _fmt(entries, typ): + subset = [e for e in entries if e.get("typ") == typ] + if not subset: + return " (keine Einträge)" + lines = [] + for e in subset[:10]: # maximal 10 pro Typ + line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}" + if e.get("naechstes"): + line += f" (nächste Fälligkeit: {e['naechstes']})" + if e.get("notiz"): + line += f" — {e['notiz']}" + lines.append(line) + return "\n".join(lines) + + heute = __import__("datetime").date.today().isoformat() + prompt = f""" +Hund: {dog_info.get('name')}, {dog_info.get('rasse', 'unbekannt')}, +Geburtstag: {dog_info.get('geburtstag', 'unbekannt')}, Gewicht: {dog_info.get('gewicht_kg', '?')} kg + +Heutiges Datum: {heute} + +=== IMPFUNGEN === +{_fmt(health_data, 'impfung')} + +=== ENTWURMUNG === +{_fmt(health_data, 'entwurmung')} + +=== TIERARZTBESUCHE === +{_fmt(health_data, 'tierarzt')} + +=== MEDIKAMENTE === +{_fmt(health_data, 'medikament')} + +=== ALLERGIEN === +{_fmt(health_data, 'allergie')} + +Erstelle einen strukturierten Gesundheitsbericht mit: +1. Aktueller Status (2-3 Sätze) +2. Fällige / überfällige Impfungen und Termine +3. Wichtige Hinweise für den nächsten Tierarztbesuch +4. Allgemeine Empfehlungen + +Schreibe klar und verständlich für den Hundebesitzer. +""" + return await complete( + prompt, system, + max_tokens=700, + requires_premium=False, # Kostenlos für alle + user_is_premium=user_is_premium, + ) + + # ------------------------------------------------------------------ # Status-Endpoint (für Admin/Debug) # ------------------------------------------------------------------ diff --git a/backend/main.py b/backend/main.py index 96d0236..88c7e1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,6 +89,10 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +@app.get("/favicon.ico") +async def favicon(): + return FileResponse(f"{STATIC_DIR}/icons/favicon.ico") + @app.get("/manifest.json") async def manifest(): return FileResponse(f"{STATIC_DIR}/manifest.json") diff --git a/backend/requirements.txt b/backend/requirements.txt index 904fb5d..29e57bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,6 @@ fastapi==0.115.0 +Pillow==11.2.1 +pillow-heif==0.22.0 uvicorn[standard]==0.30.6 python-multipart==0.0.9 pydantic[email]==2.8.2 diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 56f348d..ba59f6d 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -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.") diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index ca63968..e59b01d 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -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) diff --git a/backend/routes/health.py b/backend/routes/health.py index db2c27d..8911d99 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -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)) diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 4916a6f..9459c61 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -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}