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.
878 lines
37 KiB
Python
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}
|