banyaro/backend/routes/adoption.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

535 lines
24 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 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]