""" 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 math import logging import asyncio import httpx from datetime import datetime, timedelta from fastapi import APIRouter, Query, BackgroundTasks from database import db logger = logging.getLogger(__name__) router = APIRouter() PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") # ------------------------------------------------------------------ # Haversine — Distanz in km # ------------------------------------------------------------------ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371.0 p1 = math.radians(lat1) p2 = math.radians(lat2) dp = math.radians(lat2 - lat1) dl = math.radians(lon2 - lon1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * R * math.asin(math.sqrt(a)) # ------------------------------------------------------------------ # 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(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(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}