Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen

This commit is contained in:
rene 2026-05-03 11:09:39 +02:00
parent f4052fbb7d
commit 747c353444
20 changed files with 3115 additions and 63 deletions

View file

@ -13,10 +13,17 @@ import os
import math
import logging
import asyncio
import uuid
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks
from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
logger = logging.getLogger(__name__)
router = APIRouter()
@ -290,3 +297,251 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
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] = None
# ------------------------------------------------------------------
# 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(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
@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]