- 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
292 lines
15 KiB
Python
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}
|