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
This commit is contained in:
parent
031c6028ac
commit
742ad189e8
26 changed files with 5734 additions and 27 deletions
292
backend/routes/adoption.py
Normal file
292
backend/routes/adoption.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""
|
||||
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue