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:
parent
96e7a97b52
commit
6f48ec581d
8 changed files with 570 additions and 52 deletions
|
|
@ -88,6 +88,14 @@ def init_db():
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC);
|
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
|
-- GESUNDHEIT
|
||||||
CREATE TABLE IF NOT EXISTS health (
|
CREATE TABLE IF NOT EXISTS health (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -186,6 +194,15 @@ def init_db():
|
||||||
PRIMARY KEY (walk_id, user_id)
|
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
|
-- FORUM
|
||||||
CREATE TABLE IF NOT EXISTS forum_threads (
|
CREATE TABLE IF NOT EXISTS forum_threads (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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.")
|
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.")
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,71 @@ Bewerte als JSON:
|
||||||
return {"plausibel": True, "typ": "unbekannt", "hinweis": description}
|
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)
|
# Status-Endpoint (für Admin/Debug)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
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")
|
@app.get("/manifest.json")
|
||||||
async def manifest():
|
async def manifest():
|
||||||
return FileResponse(f"{STATIC_DIR}/manifest.json")
|
return FileResponse(f"{STATIC_DIR}/manifest.json")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
|
Pillow==11.2.1
|
||||||
|
pillow-heif==0.22.0
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
pydantic[email]==2.8.2
|
pydantic[email]==2.8.2
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,23 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
|
|
||||||
class DiaryCreate(BaseModel):
|
class DiaryCreate(BaseModel):
|
||||||
datum: Optional[str] = None # ISO date, default heute
|
datum: Optional[str] = None # ISO date, default heute
|
||||||
typ: str = "eintrag"
|
typ: str = "eintrag"
|
||||||
titel: Optional[str] = None
|
titel: Optional[str] = None
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
tags: Optional[list] = None
|
tags: Optional[list] = None
|
||||||
gps_lat: Optional[float] = None
|
gps_lat: Optional[float] = None
|
||||||
gps_lon: Optional[float] = None
|
gps_lon: Optional[float] = None
|
||||||
is_milestone: bool = False
|
is_milestone: bool = False
|
||||||
|
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
|
||||||
|
|
||||||
|
|
||||||
class DiaryUpdate(BaseModel):
|
class DiaryUpdate(BaseModel):
|
||||||
titel: Optional[str] = None
|
titel: Optional[str] = None
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
tags: Optional[list] = None
|
tags: Optional[list] = None
|
||||||
is_milestone: Optional[bool] = 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):
|
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
|
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")
|
@router.get("/{dog_id}/diary")
|
||||||
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||||
user=Depends(get_current_user)):
|
user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], 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(
|
rows = conn.execute(
|
||||||
"""SELECT * FROM diary WHERE dog_id=?
|
"""SELECT DISTINCT d.* FROM diary d
|
||||||
ORDER BY datum DESC, created_at DESC
|
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 ?""",
|
LIMIT ? OFFSET ?""",
|
||||||
(dog_id, limit, offset)
|
(dog_id, dog_id, limit, offset)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
entries = []
|
ids = [r["id"] for r in rows]
|
||||||
for r in rows:
|
dogs_map = _fetch_dog_ids(conn, ids)
|
||||||
e = dict(r)
|
|
||||||
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
return [_entry_dict(r, dogs_map) for r in rows]
|
||||||
entries.append(e)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{dog_id}/diary", status_code=201)
|
@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:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
_own_dog(dog_id, user["id"], conn)
|
||||||
|
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO diary
|
"""INSERT INTO diary
|
||||||
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
|
(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",
|
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
_set_dog_ids(conn, entry["id"], all_dogs)
|
||||||
|
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||||
|
|
||||||
e = dict(entry)
|
return _entry_dict(entry, dogs_map)
|
||||||
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{dog_id}/diary/{entry_id}")
|
@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:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
_own_dog(dog_id, user["id"], conn)
|
||||||
row = conn.execute(
|
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()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||||
e = dict(row)
|
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
||||||
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
|
||||||
return e
|
return _entry_dict(row, dogs_map)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{dog_id}/diary/{entry_id}")
|
@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)):
|
user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], 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:
|
if "tags" in fields:
|
||||||
fields["tags"] = json.dumps(fields["tags"])
|
fields["tags"] = json.dumps(fields["tags"])
|
||||||
if not fields:
|
if fields:
|
||||||
raise HTTPException(400, "Keine Änderungen.")
|
# primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
|
||||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?",
|
f"UPDATE diary SET {set_clause} WHERE id=?",
|
||||||
list(fields.values()) + [entry_id, dog_id]
|
list(fields.values()) + [entry_id]
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
|
||||||
e = dict(row)
|
# Hunde-Zuweisung aktualisieren
|
||||||
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
if data.dog_ids is not None:
|
||||||
return e
|
# 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)
|
@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:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
_own_dog(dog_id, user["id"], conn)
|
||||||
entry = conn.execute(
|
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()
|
).fetchone()
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,28 @@ async def upload_photo(
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Datei speichern
|
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
|
||||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
import io
|
||||||
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}"
|
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)
|
path = os.path.join(MEDIA_DIR, "dogs", filename)
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
content = await file.read()
|
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,283 @@
|
||||||
"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
|
"""BAN YARO — Gesundheit & Impfpass Routes"""
|
||||||
from fastapi import APIRouter
|
|
||||||
router = APIRouter()
|
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))
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ class PoisonCreate(BaseModel):
|
||||||
typ: str = "unbekannt"
|
typ: str = "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
class PoisonResolve(BaseModel):
|
||||||
|
grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
|
# 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
|
# Nur der Melder selbst oder ein Admin
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post("/{poison_id}/resolve")
|
@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:
|
with db() as conn:
|
||||||
entry = conn.execute(
|
entry = conn.execute(
|
||||||
"SELECT * FROM poison WHERE id=?", (poison_id,)
|
"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)
|
e = dict(entry)
|
||||||
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
|
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
|
||||||
raise HTTPException(403, "Keine Berechtigung.")
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue