- Volltext-Suche im Tagebuch (LIKE über Titel/Text/Tags, Debounce 350ms)
- Digitaler Heimtierausweis als druckbare HTML-Seite (/ausweis/{dog_id})
Enthält Impfungen, Medikamente, Allergien, Tierärzte, Chip-Nr.
- Hund teilen: Einladungslink-System (dog_shares-Tabelle, /teilen/{token})
Geteilte Hunde erscheinen in der Hundeliste, Tagebuch/Gesundheit lesbar
- Widget-Seite /#widget: zufälliges Tagebuchbild + nächste Erinnerung
Als PWA-Shortcut im Manifest verankert
- SW-Cache by-v144, APP_VER 117
256 lines
9.3 KiB
Python
256 lines
9.3 KiB
Python
"""BAN YARO — Tagebuch Routes"""
|
|
|
|
import os, uuid, json
|
|
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
|
|
import ki as KI
|
|
|
|
router = APIRouter()
|
|
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
|
|
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
|
|
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
|
|
|
|
|
|
def _own_dog(dog_id: int, user_id: int, conn):
|
|
"""Eigener Hund ODER geteilter Hund (angenommene Einladung)."""
|
|
dog = conn.execute(
|
|
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
|
|
).fetchone()
|
|
if not dog:
|
|
dog = conn.execute(
|
|
"""SELECT d.id FROM dogs d
|
|
JOIN dog_shares ds ON ds.dog_id = d.id
|
|
WHERE d.id=? AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL""",
|
|
(dog_id, user_id)
|
|
).fetchone()
|
|
if not dog:
|
|
raise HTTPException(404, "Hund nicht gefunden.")
|
|
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,
|
|
q: Optional[str] = None,
|
|
user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
_own_dog(dog_id, user["id"], conn)
|
|
if q:
|
|
pattern = f"%{q}%"
|
|
rows = conn.execute(
|
|
"""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 = ?)
|
|
AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?)
|
|
ORDER BY d.datum DESC, d.created_at DESC
|
|
LIMIT ? OFFSET ?""",
|
|
(dog_id, dog_id, pattern, pattern, pattern, limit, offset)
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"""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, dog_id, limit, offset)
|
|
).fetchall()
|
|
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)
|
|
async def create_diary(dog_id: int, data: DiaryCreate,
|
|
user=Depends(get_current_user)):
|
|
tags = data.tags or []
|
|
|
|
# KI: Auto-Tags wenn Text vorhanden (lokal, kostenlos)
|
|
if data.text and len(data.text) > 10:
|
|
try:
|
|
ai_tags = await KI.diary_tags(data.text)
|
|
tags = list(set(tags + ai_tags))
|
|
except Exception:
|
|
pass
|
|
|
|
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)
|
|
VALUES (?,
|
|
COALESCE(?, date('now')),
|
|
?,?,?,?,?,?,?)""",
|
|
(dog_id, data.datum, data.typ, data.titel, data.text,
|
|
json.dumps(tags), data.gps_lat, data.gps_lon, int(data.is_milestone))
|
|
)
|
|
entry = conn.execute(
|
|
"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"]])
|
|
|
|
return _entry_dict(entry, dogs_map)
|
|
|
|
|
|
@router.get("/{dog_id}/diary/{entry_id}")
|
|
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 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.")
|
|
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
|
|
|
return _entry_dict(row, dogs_map)
|
|
|
|
|
|
@router.patch("/{dog_id}/diary/{entry_id}")
|
|
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)
|
|
|
|
# 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 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)
|
|
async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
_own_dog(dog_id, user["id"], conn)
|
|
conn.execute(
|
|
"DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
|
)
|
|
|
|
|
|
@router.post("/{dog_id}/diary/{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:
|
|
_own_dog(dog_id, user["id"], conn)
|
|
entry = conn.execute(
|
|
"""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.")
|
|
|
|
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
|
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
|
|
path = os.path.join(MEDIA_DIR, "diary", 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/diary/{filename}"
|
|
with db() as conn:
|
|
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
|
|
|
|
return {"media_url": media_url}
|