banyaro/backend/routes/health.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
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
2026-05-27 13:40:30 +02:00

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