banyaro/backend/routes/diary.py
rene f8d354749d Feature: Tagebuch Ort/POI, Foto/Video-Edit, Modal-UX, iOS-Fixes
Tagebuch — Ort/POI (DayOne-ähnlich):
- diary.location_name Spalte, DiaryCreate/Update mit gps_lat/lon/location_name
- GET /api/dogs/{id}/diary/nearby: Overpass + Nominatim (vor {entry_id}-Route)
- Mini-Karte im Edit-Formular: Leaflet lazy, Edit-Modus, SVG-Pin
- Meilenstein-Toggle: Button statt Checkbox, Filter in Toolbar
- Datenmigration: 97 Ort-Einträge aus text → location_name

Tagebuch — Foto/Video:
- Foto/Video im Edit: Ersetzen + Löschen, DELETE media endpoint
- Media-Picker: Kamera/Mediathek/Datei Buttons
- Video-Wiedergabe (<video controls> in Detail + Edit)

Modal-UX (alle Edit-Karten vereinheitlicht):
- Footer-Pattern: [Speichern vollbreit] / [Löschen][Abbrechen]
- diary, dog-profile, events, health, places, walks, settings, sitting
- Löschen aus Detail-Modal → Edit-Form verschoben

iOS Mobile-Fixes:
- Auto-Zoom: input/select/textarea font-size 16px !important
- Scroll-Through: html.modal-open + touch-action:none auf Overlay
- Kein position:fixed mehr auf body (kein Scroll-Sprung)

PWA & Icons:
- icon-512-any.png + icon-192-any.png (quadratisch, maskable)
- manifest.json: purpose any/maskable getrennt
- Gesundheits-Icon: syringe → first-aid

Import-Fix:
- _HTMLStripper überspringt video/audio/script → kein "Video nicht gefunden" mehr
2026-04-18 11:56:54 +02:00

427 lines
17 KiB
Python

"""BAN YARO — Tagebuch Routes"""
import os, uuid, json, math
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
import httpx
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
location_name: Optional[str] = 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
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
location_name: Optional[str] = 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, milestone: int = 0,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
if q:
pattern = f"%{q}%"
rows = conn.execute(
f"""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 ?)
{extra}
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(
f"""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 = ?) {extra}
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, location_name, 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, data.location_name, 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)
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
@router.get("/{dog_id}/diary/nearby")
async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)):
results = []
# 1. User-eigene Places
with db() as conn:
_own_dog(dog_id, user["id"], conn)
places = conn.execute(
"SELECT name, typ, lat, lon FROM places WHERE user_id=? AND lat IS NOT NULL",
(user["id"],)
).fetchall()
for p in places:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"],
"distance_m": int(km * 1000), "source": "places"})
# 2. Gecachte OSM-POIs (nur wenn vorhanden)
osm = conn.execute(
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall()
for p in osm:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2:
results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"],
"distance_m": int(km * 1000), "source": "osm"})
async with httpx.AsyncClient(timeout=6) as client:
# 3. Overpass: benannte POIs — 800m, bei leerer Antwort auf 2000m erweitern
try:
def _overpass_q(radius):
return (
f'[out:json][timeout:6];'
f'('
f' node["name"]["tourism"](around:{radius},{lat},{lon});'
f' node["name"]["historic"](around:{radius},{lat},{lon});'
f' node["name"]["leisure"](around:{radius},{lat},{lon});'
f' node["name"]["amenity"](around:{radius},{lat},{lon});'
f' node["name"]["shop"](around:{radius},{lat},{lon});'
f' way["name"]["tourism"](around:{radius},{lat},{lon});'
f' way["name"]["historic"](around:{radius},{lat},{lon});'
f' way["name"]["leisure"](around:{radius},{lat},{lon});'
f');'
f'out center;'
)
overpass_q = _overpass_q(800)
ov = await client.post(
"https://overpass-api.de/api/interpreter",
data={"data": overpass_q},
headers={"User-Agent": "BanYaro/1.0"},
)
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
# Kein Ergebnis → Radius auf 2 km erweitern
if not elements:
ov2 = await client.post(
"https://overpass-api.de/api/interpreter",
data={"data": _overpass_q(2000)},
headers={"User-Agent": "BanYaro/1.0"},
)
elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
if True:
for el in elements:
n = el.get("tags", {}).get("name")
if not n:
continue
elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon:
km = _haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in
["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place")
results.append({"name": n, "type": typ,
"lat": elat, "lon": elon,
"distance_m": int(km * 1000), "source": "overpass"})
except Exception:
pass
# 4. Nominatim Reverse-Geocode als Adress-Fallback
try:
r = await client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json",
"zoom": 16, "namedetails": 1, "addressdetails": 1},
headers={"User-Agent": "BanYaro/1.0"},
)
if r.status_code == 200:
d = r.json()
# namedetails kann None sein wenn kein benanntes Feature vorhanden
name = (d.get("name") or
(d.get("namedetails") or {}).get("name"))
if not name:
addr = d.get("address", {})
name = (addr.get("village") or addr.get("hamlet") or
addr.get("town") or addr.get("city") or
addr.get("suburb") or
d.get("display_name", "").split(",")[0].strip())
if name:
results.append({"name": name, "type": "address",
"lat": lat, "lon": lon,
"distance_m": 0, "source": "nominatim"})
except Exception:
pass
seen, unique = set(), []
for r in sorted(results, key=lambda x: x["distance_m"]):
key = r["name"].lower()
if key not in seen:
seen.add(key); unique.append(r)
if len(unique) >= 12:
break
return unique
@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 — location_name/gps_* dürfen explizit auf None gesetzt werden
raw = data.model_dump(exclude={"dog_ids"})
NULLABLE = {"location_name", "gps_lat", "gps_lon"}
fields = {k: v for k, v in raw.items() if v is not None or k in NULLABLE}
if "tags" in fields and fields["tags"] is not None:
fields["tags"] = json.dumps(fields["tags"])
if fields:
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.")
ALLOWED = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
"video/mp4", "video/quicktime", "video/webm", "video/x-m4v",
}
ct = file.content_type or ""
if ct not in ALLOWED:
ext_low = os.path.splitext(file.filename or "")[1].lower()
if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
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())
# Altes Medium von Disk löschen wenn vorhanden
with db() as conn:
old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone()
if old and old["media_url"]:
old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/"))
try: os.remove(old_path)
except OSError: pass
media_url = f"/media/diary/{filename}"
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
return {"media_url": media_url}
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
async def delete_media(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 media_url FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
if row["media_url"]:
path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/"))
try: os.remove(path)
except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
return unique