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