PYDANTIC max_length (38 Routen, ~400 Field-Constraints): Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.). Pragmatische Limits: - Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000 - Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100 - Hund-Name/Rasse: 80 · Hund-Bio: 2000 Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py, notes.py, auth.py, profile.py. Manuelle len()-Checks in profile, chat, ki entfernt (jetzt durch Field abgedeckt). PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail): - test_security.py: require_owner (Places GET/PATCH/DELETE mit Fremduser → 403), JWT-Blacklist (Logout invalidiert Token), Login-Lockout (5 Fehlversuche → 429 + Retry-After Header) - test_race.py: Invoice-Counter (20 parallele Threads, alle unique), Founder-Number (atomare Vergabe, voll bei 100) - test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text 50k → 422 (verifiziert Pydantic max_length-Sweep) A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast): - #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44 - dog-profile Wrapped-Slider Prev/Next 40→44 - forum-Lightbox Close 40→44 - --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS) - --c-text-muted Dark: #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS) - Branding-Farben unangetastet
758 lines
31 KiB
Python
758 lines
31 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, Field
|
|
from typing import Optional
|
|
from database import db
|
|
from auth import get_current_user
|
|
from media_utils import safe_media_path
|
|
|
|
router = APIRouter()
|
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|
|
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
|
|
|
# Erlaubte Typen
|
|
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schemas
|
|
# ------------------------------------------------------------------
|
|
class HealthCreate(BaseModel):
|
|
typ: str = Field(..., max_length=50)
|
|
bezeichnung: Optional[str] = Field(None, max_length=200)
|
|
datum: str = Field(..., max_length=32)
|
|
naechstes: Optional[str] = Field(None, max_length=32)
|
|
notiz: Optional[str] = Field(None, max_length=5000)
|
|
# Gewicht
|
|
wert: Optional[float] = None
|
|
einheit: Optional[str] = Field("kg", max_length=20)
|
|
# Impfung
|
|
charge_nr: Optional[str] = Field(None, max_length=100)
|
|
tierarzt_name: Optional[str] = Field(None, max_length=200)
|
|
# Tierarztbesuch
|
|
kosten: Optional[float] = None
|
|
diagnose: Optional[str] = Field(None, max_length=2000)
|
|
# Medikament
|
|
dosierung: Optional[str] = Field(None, max_length=200)
|
|
haeufigkeit: Optional[str] = Field(None, max_length=200)
|
|
aktiv: Optional[int] = 1
|
|
bis_datum: Optional[str] = Field(None, max_length=32)
|
|
# Allergie
|
|
schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer
|
|
reaktion: Optional[str] = Field(None, max_length=1000)
|
|
erinnerung: Optional[int] = 1
|
|
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
|
|
# Tierarzt-Verknüpfung
|
|
tierarzt_id: Optional[int] = None
|
|
# Züchter
|
|
deckdatum: Optional[str] = Field(None, max_length=32)
|
|
wurftermin: Optional[str] = Field(None, max_length=32)
|
|
|
|
|
|
class HealthUpdate(BaseModel):
|
|
bezeichnung: Optional[str] = Field(None, max_length=200)
|
|
datum: Optional[str] = Field(None, max_length=32)
|
|
naechstes: Optional[str] = Field(None, max_length=32)
|
|
notiz: Optional[str] = Field(None, max_length=5000)
|
|
wert: Optional[float] = None
|
|
einheit: Optional[str] = Field(None, max_length=20)
|
|
charge_nr: Optional[str] = Field(None, max_length=100)
|
|
tierarzt_name: Optional[str] = Field(None, max_length=200)
|
|
kosten: Optional[float] = None
|
|
diagnose: Optional[str] = Field(None, max_length=2000)
|
|
dosierung: Optional[str] = Field(None, max_length=200)
|
|
haeufigkeit: Optional[str] = Field(None, max_length=200)
|
|
aktiv: Optional[int] = None
|
|
bis_datum: Optional[str] = Field(None, max_length=32)
|
|
schweregrad: Optional[str] = Field(None, max_length=50)
|
|
reaktion: Optional[str] = Field(None, max_length=1000)
|
|
erinnerung: Optional[int] = None
|
|
intervall_tage: Optional[int] = None
|
|
tierarzt_id: Optional[int] = None
|
|
deckdatum: Optional[str] = Field(None, max_length=32)
|
|
wurftermin: Optional[str] = Field(None, max_length=32)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Hilfsfunktionen
|
|
# ------------------------------------------------------------------
|
|
def _sync_gewicht(conn, dog_id: int):
|
|
"""Aktualisiert dogs.gewicht_kg auf den neuesten Gewichtseintrag (nach datum)."""
|
|
conn.execute(
|
|
"""UPDATE dogs SET gewicht_kg = (
|
|
SELECT wert FROM health
|
|
WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL
|
|
ORDER BY datum DESC, id DESC LIMIT 1
|
|
) WHERE id=?""",
|
|
(dog_id, dog_id)
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
def _fetch_media_items(conn, entry_ids: list) -> dict:
|
|
"""Gibt {health_id: [{id, url, media_type}, ...]} zurück."""
|
|
if not entry_ids:
|
|
return {}
|
|
ph = ",".join("?" * len(entry_ids))
|
|
rows = conn.execute(
|
|
f"SELECT id, health_id, url, media_type FROM health_media "
|
|
f"WHERE health_id IN ({ph}) ORDER BY health_id, sort_order",
|
|
entry_ids
|
|
).fetchall()
|
|
result = {}
|
|
for r in rows:
|
|
result.setdefault(r["health_id"], []).append({
|
|
"id": r["id"], "url": r["url"], "media_type": r["media_type"]
|
|
})
|
|
return result
|
|
|
|
|
|
def _entry_with_media(row, media_map: dict) -> dict:
|
|
e = dict(row)
|
|
e["media_items"] = media_map.get(e["id"], [])
|
|
return e
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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()
|
|
ids = [r["id"] for r in rows]
|
|
media_map = _fetch_media_items(conn, ids)
|
|
return [_entry_with_media(r, media_map) 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, tierarzt_id,
|
|
deckdatum, wurftermin)
|
|
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, data.tierarzt_id,
|
|
data.deckdatum, data.wurftermin)
|
|
)
|
|
row = conn.execute(
|
|
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
|
(dog_id,)
|
|
).fetchone()
|
|
media_map = _fetch_media_items(conn, [row["id"]])
|
|
if data.typ == 'gewicht':
|
|
_sync_gewicht(conn, dog_id)
|
|
return _entry_with_media(row, media_map)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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()
|
|
media_map = _fetch_media_items(conn, [entry_id])
|
|
if row["typ"] == 'gewicht':
|
|
_sync_gewicht(conn, dog_id)
|
|
return _entry_with_media(row, media_map)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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, typ FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
|
).fetchone()
|
|
if not entry:
|
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
|
was_gewicht = entry["typ"] == 'gewicht'
|
|
conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
|
|
if was_gewicht:
|
|
_sync_gewicht(conn, dog_id)
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# DELETE /api/dogs/{dog_id}/health/{id}/dokument — Datei löschen
|
|
# ------------------------------------------------------------------
|
|
@router.delete("/{dog_id}/health/{entry_id}/dokument")
|
|
async def delete_dokument(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 datei_url FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
|
).fetchone()
|
|
if not entry:
|
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
|
|
|
datei_url = entry["datei_url"]
|
|
if datei_url:
|
|
path = safe_media_path(MEDIA_DIR, datei_url)
|
|
if path and os.path.isfile(path):
|
|
os.remove(path)
|
|
|
|
conn.execute(
|
|
"UPDATE health SET datei_url=NULL, datei_typ=NULL WHERE id=?", (entry_id,)
|
|
)
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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/{entry_id}/media — Datei-Upload (Multi)
|
|
# ------------------------------------------------------------------
|
|
@router.post("/{dog_id}/health/{entry_id}/media")
|
|
async def upload_media(
|
|
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 ALLOWED_EXTENSIONS:
|
|
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())
|
|
|
|
media_url = f"/media/health/{filename}"
|
|
media_type = "pdf" if ext == ".pdf" else "image"
|
|
|
|
with db() as conn:
|
|
max_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) FROM health_media WHERE health_id=?",
|
|
(entry_id,)
|
|
).fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO health_media (health_id, url, media_type, sort_order) VALUES (?,?,?,?)",
|
|
(entry_id, media_url, media_type, max_order + 1)
|
|
)
|
|
new_id = conn.execute(
|
|
"SELECT id FROM health_media WHERE health_id=? ORDER BY id DESC LIMIT 1",
|
|
(entry_id,)
|
|
).fetchone()["id"]
|
|
|
|
return {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# DELETE /api/dogs/{dog_id}/health/{entry_id}/media/{media_id}
|
|
# ------------------------------------------------------------------
|
|
@router.delete("/{dog_id}/health/{entry_id}/media/{media_id}", status_code=204)
|
|
async def delete_media_item(dog_id: int, entry_id: int, media_id: int,
|
|
user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
_check_dog_owner(conn, dog_id, user["id"])
|
|
row = conn.execute(
|
|
"SELECT hm.id, hm.url FROM health_media hm "
|
|
"JOIN health h ON h.id = hm.health_id "
|
|
"WHERE hm.id=? AND hm.health_id=? AND h.dog_id=?",
|
|
(media_id, entry_id, dog_id)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Medium nicht gefunden.")
|
|
file_path = safe_media_path(MEDIA_DIR, row["url"])
|
|
if file_path:
|
|
try: os.remove(file_path)
|
|
except OSError: pass
|
|
conn.execute("DELETE FROM health_media WHERE id=?", (media_id,))
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf
|
|
# ------------------------------------------------------------------
|
|
@router.get("/{dog_id}/health/gewicht")
|
|
async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
_check_dog_owner(conn, dog_id, user["id"])
|
|
rows = conn.execute(
|
|
"""SELECT datum, wert AS gewicht FROM health
|
|
WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL
|
|
ORDER BY datum ASC""",
|
|
(dog_id,)
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
|
|
# ------------------------------------------------------------------
|
|
class SymptomCheckRequest(BaseModel):
|
|
symptoms: str = Field(..., min_length=3, max_length=5000)
|
|
|
|
|
|
@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")),
|
|
user_id=user["id"],
|
|
)
|
|
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")),
|
|
user_id=user["id"],
|
|
)
|
|
save_error = None
|
|
try:
|
|
with db() as conn:
|
|
conn.execute(
|
|
"INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?,?,?)",
|
|
(dog_id, user["id"], result)
|
|
)
|
|
count = conn.execute(
|
|
"SELECT COUNT(*) FROM ki_health_reports WHERE dog_id=?", (dog_id,)
|
|
).fetchone()[0]
|
|
except Exception as e:
|
|
save_error = str(e)
|
|
count = 0
|
|
logger.warning(f"KI-Bericht konnte nicht gespeichert werden: {e}")
|
|
return {"zusammenfassung": result, "saved_count": count, "save_error": save_error}
|
|
except KIPremiumRequired as e:
|
|
raise HTTPException(402, str(e))
|
|
except KIUnavailableError as e:
|
|
raise HTTPException(503, str(e))
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/dogs/{dog_id}/health/ki-berichte
|
|
# ------------------------------------------------------------------
|
|
@router.get("/{dog_id}/health/ki-berichte")
|
|
async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
_check_dog_owner(conn, dog_id, user["id"])
|
|
rows = conn.execute(
|
|
"""SELECT id, bericht, erstellt_at FROM ki_health_reports
|
|
WHERE dog_id=? ORDER BY erstellt_at DESC LIMIT 5""",
|
|
(dog_id,)
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/dogs/{dog_id}/health/terminvorschlaege
|
|
# Gibt strukturierte Termin-Vorschläge auf Basis fälliger health-Einträge.
|
|
# ------------------------------------------------------------------
|
|
_TERMIN_TYPEN = {
|
|
'impfung': {'label': 'Impfung', 'beim_tierarzt': True, 'icon': 'syringe'},
|
|
'entwurmung': {'label': 'Entwurmung', 'beim_tierarzt': False, 'icon': 'pill'},
|
|
'tierarzt': {'label': 'Tierarztbesuch','beim_tierarzt': True, 'icon': 'first-aid'},
|
|
'medikament': {'label': 'Medikament', 'beim_tierarzt': False, 'icon': 'pill'},
|
|
'laeufigkeit': {'label': 'Läufigkeit', 'beim_tierarzt': False, 'icon': 'calendar'},
|
|
}
|
|
|
|
@router.get("/{dog_id}/health/terminvorschlaege")
|
|
async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
|
|
from timeutils import next_appointment_slot
|
|
from datetime import date, timedelta
|
|
|
|
today = date.today()
|
|
horizon = today + timedelta(days=30)
|
|
|
|
with db() as conn:
|
|
_check_dog_owner(conn, dog_id, user["id"])
|
|
|
|
# Einträge mit fälligem naechstes (überfällig oder in 30 Tagen)
|
|
rows = conn.execute(
|
|
"""SELECT id, typ, bezeichnung, naechstes, tierarzt_id
|
|
FROM health
|
|
WHERE dog_id=? AND naechstes IS NOT NULL
|
|
AND naechstes <= ? AND aktiv=1
|
|
ORDER BY naechstes ASC""",
|
|
(dog_id, horizon.isoformat())
|
|
).fetchall()
|
|
|
|
# Primäre Praxis des Users (erste aktive)
|
|
praxis = conn.execute(
|
|
"SELECT name, opening_hours, lat, lon FROM tieraerzte "
|
|
"WHERE user_id=? AND aktiv=1 ORDER BY id LIMIT 1",
|
|
(user["id"],)
|
|
).fetchone()
|
|
|
|
oh = praxis["opening_hours"] if praxis else None
|
|
praxis_name = praxis["name"] if praxis else None
|
|
praxis_lat = praxis["lat"] if praxis else None
|
|
praxis_lon = praxis["lon"] if praxis else None
|
|
|
|
vorschlaege = []
|
|
for r in rows:
|
|
cfg = _TERMIN_TYPEN.get(r["typ"])
|
|
if not cfg:
|
|
continue
|
|
|
|
naechstes = date.fromisoformat(r["naechstes"])
|
|
ueberfaellig = naechstes < today
|
|
delta_tage = (naechstes - today).days
|
|
|
|
# Terminfindung: bei Tierarzt-Typen Öffnungszeiten nutzen
|
|
slot_oh = oh if cfg["beim_tierarzt"] else None
|
|
# Frühestens ab morgen, aber nicht vor dem Fälligkeitsdatum wenn noch in der Zukunft
|
|
start = today if ueberfaellig else naechstes - timedelta(days=1)
|
|
datum_v, uhrzeit_v = next_appointment_slot(slot_oh, start_from=start)
|
|
|
|
vorschlaege.append({
|
|
"health_id": r["id"],
|
|
"typ": r["typ"],
|
|
"label": cfg["label"],
|
|
"icon": cfg["icon"],
|
|
"bezeichnung": r["bezeichnung"],
|
|
"naechstes": r["naechstes"],
|
|
"ueberfaellig": ueberfaellig,
|
|
"delta_tage": delta_tage,
|
|
"beim_tierarzt": cfg["beim_tierarzt"],
|
|
"datum_vorschlag": datum_v,
|
|
"uhrzeit_vorschlag": uhrzeit_v,
|
|
"praxis_name": praxis_name if cfg["beim_tierarzt"] else None,
|
|
"praxis_lat": praxis_lat if cfg["beim_tierarzt"] else None,
|
|
"praxis_lon": praxis_lon if cfg["beim_tierarzt"] else None,
|
|
})
|
|
|
|
return vorschlaege
|
|
|
|
|
|
# ==================================================================
|
|
# VERSICHERUNGS-VERWALTUNG
|
|
# ==================================================================
|
|
|
|
class InsuranceCreate(BaseModel):
|
|
anbieter: str = Field(..., min_length=1, max_length=200)
|
|
police_nr: Optional[str] = Field(None, max_length=100)
|
|
jahresbeitrag: Optional[float] = None
|
|
kontakt: Optional[str] = Field(None, max_length=500)
|
|
ablaufdatum: Optional[str] = Field(None, max_length=32)
|
|
notizen: Optional[str] = Field(None, max_length=5000)
|
|
|
|
class InsuranceUpdate(BaseModel):
|
|
anbieter: Optional[str] = Field(None, max_length=200)
|
|
police_nr: Optional[str] = Field(None, max_length=100)
|
|
jahresbeitrag: Optional[float] = None
|
|
kontakt: Optional[str] = Field(None, max_length=500)
|
|
ablaufdatum: Optional[str] = Field(None, max_length=32)
|
|
notizen: Optional[str] = Field(None, max_length=5000)
|
|
|
|
|
|
@router.get("/{dog_id}/insurance")
|
|
async def get_insurance(dog_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone() or (_ for _ in ()).throw(HTTPException(404))
|
|
rows = conn.execute(
|
|
"SELECT * FROM dog_insurance WHERE dog_id=? ORDER BY created_at DESC", (dog_id,)
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@router.post("/{dog_id}/insurance", status_code=201)
|
|
async def create_insurance(dog_id: int, data: InsuranceCreate, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
cur = conn.execute(
|
|
"""INSERT INTO dog_insurance (dog_id, anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(dog_id, data.anbieter, data.police_nr, data.jahresbeitrag, data.kontakt, data.ablaufdatum, data.notizen)
|
|
)
|
|
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (cur.lastrowid,)).fetchone()
|
|
return dict(row)
|
|
|
|
|
|
@router.patch("/{dog_id}/insurance/{ins_id}")
|
|
async def update_insurance(dog_id: int, ins_id: int, data: InsuranceUpdate, user=Depends(get_current_user)):
|
|
fields = {k: v for k, v in data.model_dump().items() if v is not None}
|
|
if not fields:
|
|
raise HTTPException(400, "Keine Änderungen.")
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
|
conn.execute(f"UPDATE dog_insurance SET {set_clause} WHERE id=? AND dog_id=?",
|
|
list(fields.values()) + [ins_id, dog_id])
|
|
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (ins_id,)).fetchone()
|
|
return dict(row) if row else {}
|
|
|
|
|
|
@router.delete("/{dog_id}/insurance/{ins_id}", status_code=204)
|
|
async def delete_insurance(dog_id: int, ins_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
conn.execute("DELETE FROM dog_insurance WHERE id=? AND dog_id=?", (ins_id, dog_id))
|
|
|
|
|
|
# ==================================================================
|
|
# VERHALTENS-PROTOKOLL
|
|
# ==================================================================
|
|
|
|
BEHAVIOR_KATEGORIEN = {
|
|
"angst": {"label": "Angst / Panik", "icon": "smiley-nervous"},
|
|
"aggression": {"label": "Aggression", "icon": "warning"},
|
|
"ueberreaktion":{"label": "Überreaktion", "icon": "lightning"},
|
|
"ressource": {"label": "Ressourcenverteidigung", "icon": "lock"},
|
|
"separation": {"label": "Trennungsangst", "icon": "house"},
|
|
"leine": {"label": "Leinenprobleme", "icon": "path"},
|
|
"sozial": {"label": "Sozialkompetenz", "icon": "users"},
|
|
"sonstiges": {"label": "Sonstiges", "icon": "note"},
|
|
}
|
|
|
|
BEHAVIOR_TRIGGER = [
|
|
"fremde_hunde", "fremde_menschen", "kinder", "laerm_feuerwerk",
|
|
"laerm_gewitter", "auto_fahrrad", "tierarzt", "allein_zuhause",
|
|
"andere_tiere", "besucher_zuhause", "sonstiges"
|
|
]
|
|
|
|
TRIGGER_LABELS = {
|
|
"fremde_hunde": "Fremde Hunde", "fremde_menschen": "Fremde Menschen",
|
|
"kinder": "Kinder", "laerm_feuerwerk": "Feuerwerk/Knaller",
|
|
"laerm_gewitter": "Gewitter", "auto_fahrrad": "Autos/Fahrräder",
|
|
"tierarzt": "Tierarztbesuch", "allein_zuhause": "Allein zuhause",
|
|
"andere_tiere": "Andere Tiere", "besucher_zuhause": "Besucher",
|
|
"sonstiges": "Sonstiges"
|
|
}
|
|
|
|
|
|
class BehaviorCreate(BaseModel):
|
|
datum: str = Field(..., max_length=32)
|
|
uhrzeit: Optional[str] = Field(None, max_length=20)
|
|
kategorie: str = Field(..., max_length=50)
|
|
intensitaet: int = 3
|
|
trigger: Optional[str] = Field(None, max_length=200)
|
|
notiz: Optional[str] = Field(None, max_length=5000)
|
|
|
|
|
|
@router.get("/{dog_id}/behavior")
|
|
async def get_behavior(dog_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
rows = conn.execute(
|
|
"SELECT * FROM behavior_log WHERE dog_id=? ORDER BY datum DESC, uhrzeit DESC LIMIT 100",
|
|
(dog_id,)
|
|
).fetchall()
|
|
return {
|
|
"entries": [dict(r) for r in rows],
|
|
"kategorien": BEHAVIOR_KATEGORIEN,
|
|
"trigger_labels": TRIGGER_LABELS,
|
|
}
|
|
|
|
|
|
@router.post("/{dog_id}/behavior", status_code=201)
|
|
async def create_behavior(dog_id: int, data: BehaviorCreate, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
if data.kategorie not in BEHAVIOR_KATEGORIEN:
|
|
raise HTTPException(400, "Unbekannte Kategorie.")
|
|
cur = conn.execute(
|
|
"""INSERT INTO behavior_log (dog_id, datum, uhrzeit, kategorie, intensitaet, trigger, notiz)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(dog_id, data.datum, data.uhrzeit, data.kategorie,
|
|
max(1, min(5, data.intensitaet)), data.trigger, data.notiz)
|
|
)
|
|
row = conn.execute("SELECT * FROM behavior_log WHERE id=?", (cur.lastrowid,)).fetchone()
|
|
return dict(row)
|
|
|
|
|
|
@router.delete("/{dog_id}/behavior/{entry_id}", status_code=204)
|
|
async def delete_behavior(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
conn.execute("DELETE FROM behavior_log WHERE id=? AND dog_id=?", (entry_id, dog_id))
|
|
|
|
|
|
@router.get("/{dog_id}/reminders")
|
|
async def get_upcoming_reminders(dog_id: int, user=Depends(get_current_user)):
|
|
"""Bevorstehende Erinnerungen der nächsten 30 Tage + überfällige."""
|
|
from datetime import timedelta
|
|
today = date.today()
|
|
in30 = today + timedelta(days=30)
|
|
with db() as conn:
|
|
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404)
|
|
rows = conn.execute(
|
|
"""SELECT id, typ, bezeichnung, naechstes
|
|
FROM health
|
|
WHERE dog_id=? AND naechstes IS NOT NULL
|
|
AND (erinnerung IS NULL OR erinnerung = 1)
|
|
AND typ IN ('impfung','entwurmung','medikament')
|
|
ORDER BY naechstes ASC""",
|
|
(dog_id,)
|
|
).fetchall()
|
|
result = []
|
|
for r in rows:
|
|
try:
|
|
d = date.fromisoformat(r["naechstes"])
|
|
except Exception:
|
|
continue
|
|
delta = (d - today).days
|
|
if delta < -30 or delta > 30:
|
|
continue
|
|
result.append({**dict(r), "delta_tage": delta, "ueberfaellig": delta < 0})
|
|
return result
|