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