"""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