""" BAN YARO — Adoption (Tierheim-Hunde in der Nähe) Strategie: 1. PetFinder API (falls API-Key gesetzt) → hat kaum deutsche Tierheime, nur als Bonus 2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten 3. Fallback: Weiterleitung zu tierheimhelden.de Caching: adoption_cache Tabelle, 24h TTL. """ import os import logging import asyncio import uuid import httpx from datetime import datetime, timedelta from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_to_user from math_utils import haversine_km MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") logger = logging.getLogger(__name__) router = APIRouter() PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") # ------------------------------------------------------------------ # Statische Tierheim-Daten (große deutsche Tierheime) # ------------------------------------------------------------------ GERMAN_SHELTERS = [ # (id, name, plz, stadt, lat, lon, url) ("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"), ("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"), ("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"), ("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"), ("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"), ("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"), ("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"), ("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"), ("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"), ("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"), ("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"), ("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"), ("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"), ("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"), ("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"), ("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"), ("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"), ("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"), ("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"), ("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"), ("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"), ("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"), ("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"), ("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"), ("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"), ] # ------------------------------------------------------------------ # PetFinder OAuth2 Token # ------------------------------------------------------------------ _pf_token = None _pf_token_exp = 0.0 async def _get_pf_token() -> str | None: global _pf_token, _pf_token_exp if not (PETFINDER_KEY and PETFINDER_SECRET): return None now = asyncio.get_event_loop().time() if _pf_token and now < _pf_token_exp - 60: return _pf_token try: async with httpx.AsyncClient(timeout=8) as client: r = await client.post( "https://api.petfinder.com/v2/oauth2/token", data={"grant_type": "client_credentials", "client_id": PETFINDER_KEY, "client_secret": PETFINDER_SECRET}, ) if r.status_code == 200: data = r.json() _pf_token = data.get("access_token") _pf_token_exp = now + data.get("expires_in", 3600) return _pf_token except Exception as e: logger.warning(f"PetFinder OAuth: {e}") return None # ------------------------------------------------------------------ # PetFinder: Hunde in der Nähe holen # ------------------------------------------------------------------ async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]: token = await _get_pf_token() if not token: return [] try: async with httpx.AsyncClient(timeout=12) as client: r = await client.get( "https://api.petfinder.com/v2/animals", headers={"Authorization": f"Bearer {token}"}, params={ "type": "dog", "location": f"{lat},{lon}", "distance": radius, "limit": 20, "sort": "distance", "status": "adoptable", }, ) if r.status_code != 200: logger.warning(f"PetFinder API: HTTP {r.status_code}") return [] animals = r.json().get("animals", []) result = [] for a in animals: org = a.get("organization_id", "") loc = a.get("contact", {}).get("address", {}) photos = a.get("photos", []) foto = photos[0].get("medium") if photos else None age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0} result.append({ "external_id": f"pf_{a['id']}", "name": a.get("name", "Unbekannt"), "rasse": ", ".join( filter(None, [ a.get("breeds", {}).get("primary"), a.get("breeds", {}).get("secondary"), ]) ) or None, "alter_jahre": age_map.get(a.get("age"), None), "geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None), "foto_url": foto, "tierheim": org, "tierheim_plz": loc.get("postcode"), "tierheim_lat": None, "tierheim_lon": None, "adoptions_url": a.get("url", "https://www.petfinder.com/"), "quelle": "petfinder", }) return result except Exception as e: logger.warning(f"PetFinder Fetch: {e}") return [] # ------------------------------------------------------------------ # Cache befüllen # ------------------------------------------------------------------ async def _refresh_cache(lat: float, lon: float, radius: int): """Holt frische Daten und schreibt sie in adoption_cache.""" animals = await _fetch_petfinder(lat, lon, radius) if not animals: return expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") with db() as conn: for a in animals: try: conn.execute(""" INSERT INTO adoption_cache (external_id, name, rasse, alter_jahre, geschlecht, foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon, adoptions_url, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(external_id) DO UPDATE SET name=excluded.name, rasse=excluded.rasse, alter_jahre=excluded.alter_jahre, geschlecht=excluded.geschlecht, foto_url=excluded.foto_url, tierheim=excluded.tierheim, tierheim_plz=excluded.tierheim_plz, tierheim_lat=excluded.tierheim_lat, tierheim_lon=excluded.tierheim_lon, adoptions_url=excluded.adoptions_url, expires_at=excluded.expires_at """, ( a["external_id"], a["name"], a["rasse"], a["alter_jahre"], a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"], a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires, )) except Exception as e: logger.warning(f"Cache insert: {e}") # ------------------------------------------------------------------ # GET /api/adoption/nearby # ------------------------------------------------------------------ @router.get("/nearby") async def adoption_nearby( lat: float = Query(..., description="Breitengrad"), lon: float = Query(..., description="Längengrad"), radius: int = Query(50, ge=5, le=200, description="Radius in km"), background_tasks: BackgroundTasks = None, ): """ Gibt Adoptionshunde in der Nähe zurück. Priorisierung: 1. Frische PetFinder-Einträge aus Cache 2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung) """ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") # ------ Cache lesen ------ cached_animals = [] with db() as conn: rows = conn.execute(""" SELECT * FROM adoption_cache WHERE expires_at > ? ORDER BY created_at DESC """, (now_str,)).fetchall() for row in rows: d = dict(row) if d.get("tierheim_lat") and d.get("tierheim_lon"): dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) if dist <= radius: d["distanz_km"] = round(dist, 1) cached_animals.append(d) else: # PetFinder-Einträge ohne Koordinaten: immer anzeigen d["distanz_km"] = None cached_animals.append(d) # ------ Cache refreshen wenn leer oder alt ------ if not cached_animals and background_tasks is not None: background_tasks.add_task(_refresh_cache, lat, lon, radius) # ------ Statische Tierheime (immer) ------ shelters = [] for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: dist = haversine_km(lat, lon, slat, slon) if dist <= radius: shelters.append({ "id": sid, "name": name, "plz": plz, "stadt": stadt, "lat": slat, "lon": slon, "url": url, "distanz_km": round(dist, 1), }) shelters.sort(key=lambda x: x["distanz_km"]) return { "animals": cached_animals[:40], "shelters": shelters[:10], "has_petfinder": bool(PETFINDER_KEY), } # ------------------------------------------------------------------ # GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim # ------------------------------------------------------------------ @router.get("/geocode") async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): """Wandelt eine PLZ in Koordinaten um (via Nominatim).""" try: async with httpx.AsyncClient(timeout=8) as client: r = await client.get( "https://nominatim.openstreetmap.org/search", params={ "q": f"{plz}, Germany", "format": "json", "limit": 1, "accept-language": "de", "countrycodes": "de", }, headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"}, ) results = r.json() if results: return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)} except Exception as e: logger.warning(f"Geocode PLZ {plz}: {e}") return {"lat": None, "lon": None, "display": plz} # ================================================================== # Community Adoption — Privates Weitervermittlungs-Board # ================================================================== class InterestBody(BaseModel): nachricht: Optional[str] = Field(None, max_length=5000) # ------------------------------------------------------------------ # GET /api/adoption/community/my — eigene Inserate # ------------------------------------------------------------------ @router.get("/community/my") def community_my(user=Depends(get_current_user)): with db() as conn: rows = conn.execute(""" SELECT ca.*, u.name AS besitzer_name, (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count FROM community_adoption ca JOIN users u ON u.id = ca.user_id WHERE ca.user_id = ? AND ca.status != 'deleted' ORDER BY ca.created_at DESC """, (user["id"],)).fetchall() return [dict(r) for r in rows] # ------------------------------------------------------------------ # GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe) # ------------------------------------------------------------------ @router.get("/community") def community_list( lat: Optional[float] = Query(None), lon: Optional[float] = Query(None), radius: float = Query(200.0, description="Radius in km (default 200)"), user=Depends(get_current_user), ): with db() as conn: rows = conn.execute(""" SELECT ca.*, u.name AS besitzer_name, (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count, (SELECT COUNT(*) FROM community_adoption_interest i2 WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested FROM community_adoption ca JOIN users u ON u.id = ca.user_id WHERE ca.status = 'active' ORDER BY ca.created_at DESC LIMIT 50 """, (user["id"],)).fetchall() result = [] for row in rows: d = dict(row) d["user_interested"] = bool(d.pop("_user_interested", 0)) if lat is not None and lon is not None and d.get("lat") and d.get("lon"): dist = haversine_km(lat, lon, d["lat"], d["lon"]) d["distanz_km"] = round(dist, 1) if dist > radius: continue else: d["distanz_km"] = None result.append(d) if lat is not None and lon is not None: result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999) return result # ------------------------------------------------------------------ # POST /api/adoption/community — Inserat erstellen # ------------------------------------------------------------------ @router.post("/community", status_code=201) async def community_create( name: str = Form(...), beschreibung: str = Form(...), rasse: str = Form(""), alter_jahre: Optional[float] = Form(None), geschlecht: str = Form(""), gruende: str = Form(""), ort: str = Form(""), plz: str = Form(""), lat: Optional[float] = Form(None), lon: Optional[float] = Form(None), dog_id: Optional[int] = Form(None), foto: Optional[UploadFile] = File(None), user=Depends(get_current_user), ): foto_url = None if foto and foto.filename: MAX_SIZE = 5 * 1024 * 1024 header = await foto.read(12) if len(header) < 3: raise HTTPException(400, "Ungültige Datei") is_jpeg = header[:3] == b"\xff\xd8\xff" is_png = header[:4] == b"\x89PNG" is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP" if not (is_jpeg or is_png or is_webp): raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt") rest = await foto.read(MAX_SIZE) if len(rest) >= MAX_SIZE: raise HTTPException(400, "Foto zu groß (max 5 MB)") data = header + rest folder = os.path.join(MEDIA_DIR, "adoption") os.makedirs(folder, exist_ok=True) filename = f"{uuid.uuid4()}.jpg" filepath = os.path.join(folder, filename) with open(filepath, "wb") as f: f.write(data) foto_url = f"/media/adoption/{filename}" with db() as conn: cur = conn.execute(""" INSERT INTO community_adoption (user_id, dog_id, name, rasse, alter_jahre, geschlecht, foto_url, beschreibung, gruende, ort, plz, lat, lon) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( user["id"], dog_id, name, rasse or None, alter_jahre, geschlecht or None, foto_url, beschreibung, gruende or None, ort or None, plz or None, lat, lon, )) new_id = cur.lastrowid row = conn.execute( "SELECT * FROM community_adoption WHERE id = ?", (new_id,) ).fetchone() return dict(row) # ------------------------------------------------------------------ # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) # ------------------------------------------------------------------ class _StatusBody(BaseModel): status: str = Field(..., max_length=50) @router.patch("/community/{listing_id}") def community_update_status( listing_id: int, body: _StatusBody, user=Depends(get_current_user), ): allowed = {"active", "reserved", "vermittelt"} if body.status not in allowed: raise HTTPException(400, f"Status muss einer von {allowed} sein") status = body.status with db() as conn: cur = conn.execute(""" UPDATE community_adoption SET status = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ? """, (status, listing_id, user["id"])) if cur.rowcount == 0: raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") return {"ok": True} # ------------------------------------------------------------------ # DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer) # ------------------------------------------------------------------ @router.delete("/community/{listing_id}") def community_delete(listing_id: int, user=Depends(get_current_user)): with db() as conn: cur = conn.execute(""" UPDATE community_adoption SET status = 'deleted', updated_at = datetime('now') WHERE id = ? AND user_id = ? """, (listing_id, user["id"])) if cur.rowcount == 0: raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") return {"ok": True} # ------------------------------------------------------------------ # POST /api/adoption/community/{id}/interest — Interesse bekunden # ------------------------------------------------------------------ @router.post("/community/{listing_id}/interest", status_code=201) def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)): nachricht = (body.nachricht if body else None) or None with db() as conn: listing = conn.execute( "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", (listing_id,) ).fetchone() if not listing: raise HTTPException(404, "Inserat nicht gefunden") if listing["user_id"] == user["id"]: raise HTTPException(400, "Eigenes Inserat") try: conn.execute(""" INSERT INTO community_adoption_interest (listing_id, user_id, nachricht) VALUES (?, ?, ?) """, (listing_id, user["id"], nachricht)) except Exception: raise HTTPException(409, "Interesse bereits bekundet") try: send_push_to_user(listing["user_id"], { "title": "Jemand interessiert sich für deinen Hund \U0001f43e", "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.", "url": "/#adoption", }) except Exception as e: logger.warning(f"Push interest: {e}") return {"ok": True} # ------------------------------------------------------------------ # DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen # ------------------------------------------------------------------ @router.delete("/community/{listing_id}/interest") def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)): with db() as conn: cur = conn.execute(""" DELETE FROM community_adoption_interest WHERE listing_id = ? AND user_id = ? """, (listing_id, user["id"])) if cur.rowcount == 0: raise HTTPException(404, "Kein Interesse gefunden") return {"ok": True} # ------------------------------------------------------------------ # GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer) # ------------------------------------------------------------------ @router.get("/community/{listing_id}/interests") def community_interests(listing_id: int, user=Depends(get_current_user)): with db() as conn: listing = conn.execute( "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", (listing_id,) ).fetchone() if not listing: raise HTTPException(404, "Inserat nicht gefunden") if listing["user_id"] != user["id"]: raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen") rows = conn.execute(""" SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url FROM community_adoption_interest i JOIN users u ON u.id = i.user_id WHERE i.listing_id = ? ORDER BY i.created_at ASC """, (listing_id,)).fetchall() return [dict(r) for r in rows]