banyaro/backend/routes/diary.py
rene 297bd22f96 Bündel 2: Zentrale Helper für DRY-Cleanup, SW by-v1114
NEUE BACKEND-MODULE:

math_utils.py
- haversine_km(lat1, lon1, lat2, lon2) — Distanz in km
- haversine_m(...) — Convenience-Wrapper in Metern
- bbox_deg_from_km(lat, radius_km) — Bounding-Box-Approximation
  für SQL-Vorfilter (statt Haversine im Python-Loop)

config.py
- DB_PATH, MEDIA_DIR, BREEDER_DOCS_DIR, SCANINPUT_DIR
- API_TIMEOUT_SHORT (5s) / DEFAULT (10s) / LONG (30s)
- HTTP_USER_AGENT, HTTP_HEADERS

errors.py
- not_found(msg), forbidden(msg), bad_request(msg), unauthorized(msg)
- conflict(msg), too_many_requests(msg, retry_after), service_unavailable(msg)
- require_or_404(row, msg) — Convenience-Helper

UI.JS ERWEITERUNGEN:

UI.time erweitert:
- formatDate(d)     → "15.03.2026"
- formatDateTime(d) → "15.03.2026, 14:30"
- weekday(d)        → "Di"
- parseISO(str)     → {year, month, day}

UI.text (neu):
- truncate(str, maxLen, ellipsis='…')
- slug(str) — URL-Slug aus String (mit DE-Umlauten)

UI.money (neu):
- format(value) → "12,34 €" (de-DE, EUR)
- formatWithSuffix(value, '/Jahr')

HAVERSINE-MIGRATION (13 Backend-Routen):
alerts.py, services.py, places.py, events.py, diary.py, playdate.py,
lost.py, poison.py, adoption.py, gassi_zeiten.py, sitting.py, routen.py,
walks.py

- Alle lokalen def _haversine/haversine_km entfernt
- Aufrufe ersetzt durch haversine_km/haversine_m je nach Einheit
- from math_utils import haversine_km|haversine_m in jeder Datei

Tests 19/19 grün.

Hinweis: Migrationen für MEDIA_DIR (19 Stellen), API-Timeouts (12),
Date-Formatter im Frontend (24) und UI.text.truncate (5) sind als
Folge-Sprints möglich. Helper sind verfügbar.
2026-05-27 11:19:06 +02:00

878 lines
37 KiB
Python

"""BAN YARO — Tagebuch Routes"""
import os, uuid, json, logging, asyncio
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, require_admin
import ki as KI
import httpx
import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time
from math_utils import haversine_km
logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
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
weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
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 _can_read_dog(dog_id: int, user_id: int, conn):
"""Eigener Hund ODER geteilter Hund ODER aktiver Sitter-Zugang (Lesezugriff)."""
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:
dog = conn.execute(
"""SELECT 1 FROM sitting_subscriptions
WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now')""",
(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 _fetch_media_items(conn, entry_ids: list[int]) -> dict:
"""Gibt {entry_id: [{url, media_type, sort_order, id, is_cover}, ...]} zurück."""
if not entry_ids:
return {}
ph = ",".join("?" * len(entry_ids))
rows = conn.execute(
f"SELECT id, diary_id, url, media_type, sort_order, is_cover FROM diary_media "
f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order",
entry_ids
).fetchall()
result = {}
for r in rows:
url = r["url"]
result.setdefault(r["diary_id"], []).append({
"id": r["id"], "url": url,
"preview_url": preview_url_from(url),
"media_type": r["media_type"], "sort_order": r["sort_order"],
"is_cover": r["is_cover"],
})
return result
def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> 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"]])
items = (media_map or {}).get(e["id"], [])
e["media_items"] = items
# cover_url: Item mit is_cover=1, Fallback auf erstes Item
cover = next((m for m in items if m.get("is_cover")), items[0] if items else None)
e["cover_url"] = cover["url"] if cover else None
e["cover_preview_url"] = preview_url_from(e["cover_url"])
return e
@router.get("/{dog_id}/diary/stats")
async def diary_stats(dog_id: int, user=Depends(get_current_user)):
"""Gesamtstatistik für das Tagebuch (unabhängig von Pagination)."""
with db() as conn:
_can_read_dog(dog_id, user["id"], conn)
total = conn.execute(
"SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
).fetchone()[0]
photos = conn.execute(
"SELECT COUNT(*) FROM diary_media dm "
"JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
).fetchone()[0]
days = conn.execute(
"SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
"WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
).fetchone()[0]
return {"entries": total, "photos": photos, "days": days}
@router.get("/{dog_id}/diary/calendar")
async def diary_calendar(dog_id: int, user=Depends(get_current_user)):
"""Alle Einträge minimal für Kalenderansicht: id, datum, cover_url."""
with db() as conn:
_can_read_dog(dog_id, user["id"], conn)
rows = conn.execute(
"""SELECT DISTINCT d.id, d.datum,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id AND dm.media_type='image'
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE (d.dog_id=? OR dd.dog_id=?)
AND d.datum IS NOT NULL
ORDER BY d.datum DESC""",
(dog_id, dog_id)
).fetchall()
result = []
for r in rows:
d = dict(r)
d["cover_preview_url"] = preview_url_from(d.get("cover_url"))
result.append(d)
return result
@router.get("/{dog_id}/diary/locations")
async def diary_locations(dog_id: int, user=Depends(get_current_user)):
"""Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht."""
with db() as conn:
_can_read_dog(dog_id, user["id"], conn)
rows = conn.execute(
"""SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon,
d.location_name, d.weather_json,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id AND dm.media_type='image'
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url,
(SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count
FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE (d.dog_id=? OR dd.dog_id=?)
AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL
ORDER BY d.datum DESC""",
(dog_id, dog_id)
).fetchall()
result = []
for r in rows:
d = dict(r)
d["cover_preview_url"] = preview_url_from(d.get("cover_url"))
result.append(d)
return result
@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:
_can_read_dog(dog_id, user["id"], conn)
# Sitter darf keine bestehenden Einträge lesen
dog = conn.execute("SELECT user_id FROM dogs WHERE id=?", (dog_id,)).fetchone()
is_owner = dog and dog["user_id"] == user["id"]
if not is_owner:
# Prüfen ob geteilter Hund (dog_shares) — darf lesen
shared = conn.execute(
"""SELECT 1 FROM dog_shares WHERE dog_id=? AND shared_with_id=? AND accepted_at IS NOT NULL""",
(dog_id, user["id"])
).fetchone()
if not shared:
# Weder Besitzer noch geteilter Nutzer → Sitter → leere Liste
return []
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)
media_map = _fetch_media_items(conn, ids)
return [_entry_dict(r, dogs_map, media_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, user_id=user["id"])
tags = list(set(tags + ai_tags))
except Exception:
pass
with db() as conn:
# Erlaubnis: eigener Hund ODER aktiver Sitter-Zugang
dog = conn.execute("SELECT user_id FROM dogs WHERE id=?", (dog_id,)).fetchone()
is_owner = dog and dog["user_id"] == user["id"]
is_sitter = conn.execute("""
SELECT 1 FROM sitting_subscriptions
WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now')
""", (dog_id, user["id"])).fetchone()
if not is_owner and not is_sitter:
# Fallback: shared dog check
try:
_own_dog(dog_id, user["id"], conn)
except HTTPException:
raise HTTPException(403, "Kein Zugriff auf diesen Hund.")
# Sitter darf nur den Gasthund als einzigen Hund eintragen
if is_sitter and not is_owner:
all_dogs = [dog_id]
else:
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
ct = safe_client_time(data.client_time)
datum = data.datum or ct[:10]
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(dog_id, datum, data.typ, data.titel, data.text,
json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone), ct)
)
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"]])
media_map = _fetch_media_items(conn, [entry["id"]])
entry_id = entry["id"]
# Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks)
if data.gps_lat is not None and data.gps_lon is not None:
weather_json = None
poi_json = None
# Wetter holen
try:
wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon)
weather_json = json.dumps(wd)
except Exception as exc:
logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
# POIs holen
try:
pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5)
if pois:
poi_json = json.dumps(pois)
except Exception as exc:
logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
# In DB speichern und Entry aktualisieren
if weather_json is not None or poi_json is not None:
with db() as conn:
conn.execute(
"UPDATE diary SET weather_json=?, poi_json=? WHERE id=?",
(weather_json, poi_json, entry_id)
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
elif data.weather_json:
# Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern
try:
json.loads(data.weather_json) # Validierung
with db() as conn:
conn.execute(
"UPDATE diary SET weather_json=? WHERE id=?",
(data.weather_json, entry_id)
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
except Exception as exc:
logger.warning("Client-weather_json ungültig: %s", exc)
return _entry_dict(entry, dogs_map, media_map)
async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list:
"""Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth)."""
results = []
try:
async with httpx.AsyncClient(timeout=6) as client:
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;'
)
ov = await client.post(
"https://overpass-api.de/api/interpreter",
data={"data": _overpass_q(800)},
headers={"User-Agent": "BanYaro/1.0"},
)
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
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 []
seen = set()
for el in elements:
n = el.get("tags", {}).get("name")
if not n or n.lower() in seen:
continue
seen.add(n.lower())
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,
"distance_m": int(km * 1000)})
if len(results) >= limit:
break
except Exception as exc:
logger.debug("_fetch_pois_for_coords Fehler: %s", exc)
return results[:limit]
@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:
_can_read_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])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map, media_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:
# Nur Besitzer des Hundes darf bearbeiten, NICHT Sitter
entry_owner = conn.execute(
"SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?",
(entry_id,)
).fetchone()
if not entry_owner or entry_owner["user_id"] != user["id"]:
# Prüfen ob geteilter Hund (dog_shares)
try:
_own_dog(dog_id, user["id"], conn)
except HTTPException:
raise HTTPException(403, "Nur der Besitzer darf Einträge bearbeiten.")
# 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])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map, media_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:
# Nur Besitzer des Hundes darf löschen, NICHT Sitter
entry_owner = conn.execute(
"SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?",
(entry_id,)
).fetchone()
if not entry_owner or entry_owner["user_id"] != user["id"]:
try:
_own_dog(dog_id, user["id"], conn)
except HTTPException:
raise HTTPException(403, "Nur der Besitzer darf Einträge löschen.")
conn.execute(
"DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
)
def _guess_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
return "pdf"
if ct.startswith("video/"):
return "video"
ext = os.path.splitext(filename or "")[1].lower()
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
return "video"
return "image"
@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",
"application/pdf",
}
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",".pdf"}:
raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.")
raw_data = await file.read()
try:
validate_upload(raw_data, file.filename or "")
except ValueError as e:
raise HTTPException(415, str(e))
# Blockierende Bild-/Video-Konvertierung in Threadpool auslagern,
# damit der Event-Loop für andere Requests frei bleibt.
loop = asyncio.get_event_loop()
raw_data, ext = await loop.run_in_executor(
None, lambda: convert_media(raw_data, file.filename or "")
)
if not ext:
ext = ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename)
media_type = _guess_media_type(ct, file.filename or "")
os.makedirs(os.path.dirname(path), exist_ok=True)
def _write_bytes(p: str, data: bytes) -> None:
with open(p, "wb") as f:
f.write(data)
await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data))
if media_type == "video":
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
elif media_type == "image":
preview_bytes = await loop.run_in_executor(
None, lambda: generate_preview(raw_data, ext)
)
if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.webp"
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
media_url = f"/media/diary/{filename}"
# Bildmaße + EXIF-GPS (nur bei Bilddateien)
exif_gps = None
img_size = None
if media_type == "image":
exif_gps = await loop.run_in_executor(None, lambda: extract_gps_from_exif(raw_data))
img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
with db() as conn:
# sort_order = nächste freie Position
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?",
(entry_id,)
).fetchone()[0]
# Erstes Item eines Eintrags wird automatisch Cover
is_cover = 1 if max_order == -1 else 0
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
(entry_id, media_url, media_type, max_order + 1, is_cover,
img_size[0] if img_size else None, img_size[1] if img_size else None)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
(entry_id,)
).fetchone()["id"]
# GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden
gps_written = False
if exif_gps:
existing = conn.execute(
"SELECT gps_lat FROM diary WHERE id=?", (entry_id,)
).fetchone()
if existing and existing["gps_lat"] is None:
conn.execute(
"UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?",
(exif_gps[0], exif_gps[1], entry_id)
)
gps_written = True
# Wetter + POI nachladen wenn GPS frisch gesetzt
if gps_written and exif_gps:
try:
wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1])
pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5)
with db() as conn:
conn.execute(
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
(json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id)
)
except Exception as e:
logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e)
resp = {"id": new_id, "url": media_url, "media_type": media_type,
"sort_order": max_order + 1, "is_cover": is_cover}
if exif_gps:
resp["exif_lat"] = exif_gps[0]
resp["exif_lon"] = exif_gps[1]
return resp
@router.delete("/{dog_id}/diary/{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:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT dm.id, dm.url FROM diary_media dm "
"JOIN diary d ON d.id = dm.diary_id "
"LEFT JOIN diary_dogs dd ON dd.diary_id = d.id "
"WHERE dm.id=? AND dm.diary_id=? AND (d.dog_id=? OR dd.dog_id=?)",
(media_id, entry_id, dog_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 diary_media WHERE id=?", (media_id,))
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_current_user)):
"""Legacy-Endpoint: löscht media_url aus dem diary-Datensatz (Rückwärtskompatibilität)."""
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 = safe_media_path(MEDIA_DIR, row["media_url"])
if path:
try: os.remove(path)
except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
@router.patch("/{dog_id}/diary/{entry_id}/media/{media_id}/cover", status_code=200)
async def set_cover_media(dog_id: int, entry_id: int, media_id: int,
user=Depends(get_current_user)):
"""Setzt ein Medium als Cover-Bild (is_cover=1), alle anderen auf 0."""
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT dm.id FROM diary_media dm "
"JOIN diary d ON d.id = dm.diary_id "
"LEFT JOIN diary_dogs dd ON dd.diary_id = d.id "
"WHERE dm.id=? AND dm.diary_id=? AND (d.dog_id=? OR dd.dog_id=?)",
(media_id, entry_id, dog_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Medium nicht gefunden.")
# Alle Items dieses Eintrags auf is_cover=0, dann das gewählte auf 1
conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,))
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
return {"ok": True}
# ------------------------------------------------------------------
# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge
# ------------------------------------------------------------------
@router.post("/admin/enrich-metadata", status_code=200)
async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)):
"""Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach."""
with db() as conn:
rows = conn.execute(
"""SELECT id, gps_lat, gps_lon FROM diary
WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
AND (weather_json IS NULL OR poi_json IS NULL)
LIMIT ?""",
(limit,)
).fetchall()
enriched = 0
skipped = 0
for row in rows:
entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"]
weather_json = None
poi_json = None
try:
wd = await weather_mod.get_weather_for_location(lat, lon)
weather_json = json.dumps(wd)
except Exception as e:
logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e)
try:
pois = await _fetch_pois_for_coords(lat, lon, limit=5)
if pois:
poi_json = json.dumps(pois)
except Exception as e:
logger.warning("enrich-metadata POI id=%s: %s", entry_id, e)
if weather_json is not None or poi_json is not None:
with db() as conn:
conn.execute(
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
(weather_json, poi_json, entry_id)
)
enriched += 1
else:
skipped += 1
with db() as conn:
remaining = conn.execute(
"""SELECT COUNT(*) FROM diary
WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)"""
).fetchone()[0]
return {"enriched": enriched, "skipped": skipped, "remaining": remaining}