banyaro/backend/routes/adoption.py
rene 742ad189e8 Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)
- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
2026-05-02 09:29:48 +02:00

292 lines
15 KiB
Python

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