banyaro/backend/routes/playdate.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

351 lines
12 KiB
Python

"""BAN YARO — Playdate-Matching"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_km
router = APIRouter()
logger = logging.getLogger(__name__)
def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag:
return None
try:
from datetime import date
geb = date.fromisoformat(geburtstag[:10])
today = date.today()
monate = (today.year - geb.year) * 12 + (today.month - geb.month)
if today.day < geb.day:
monate -= 1
if monate < 0:
return None
if monate < 24:
return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
jahre = monate // 12
return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
except Exception:
return None
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = Field(None, max_length=300)
radius_km: int = 10
beschreibung: Optional[str] = Field(None, max_length=2000)
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = Field(None, max_length=2000)
class RequestPatch(BaseModel):
status: str = Field(..., max_length=30) # accepted | declined
# ------------------------------------------------------------------
# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
# ------------------------------------------------------------------
def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
a, b = (min(user_a, user_b), max(user_a, user_b))
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
(a, b)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
(a, b)
)
return cur.lastrowid
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@router.get("/nearby")
async def nearby(lat: float, lon: float, radius: int = 10,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT pl.id AS listing_id,
pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
d.id AS dog_id, d.name AS dog_name, d.rasse,
d.geburtstag, d.foto_url, d.geschlecht
FROM playdate_listings pl
JOIN dogs d ON d.id = pl.dog_id
WHERE pl.aktiv = 1
AND pl.user_id != ?
""", (uid,)).fetchall()
result = []
for r in rows:
dist = haversine_km(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],
"dog_id": r["dog_id"],
"dog_name": r["dog_name"],
"rasse": r["rasse"],
"alter": _calc_alter(r["geburtstag"]),
"geschlecht": r["geschlecht"],
"foto_url": r["foto_url"],
"ort_name": r["ort_name"],
"beschreibung": r["beschreibung"],
"entfernung_km": round(dist, 1),
})
result.sort(key=lambda x: x["entfernung_km"])
return result
@router.put("/listing", status_code=200)
async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Sicherstellen dass der Hund dem User gehört
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
existing = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=?",
(data.dog_id,)
).fetchone()
if existing:
conn.execute("""
UPDATE playdate_listings
SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
aktiv=1, updated_at=datetime('now')
WHERE dog_id=?
""", (data.lat, data.lon, data.ort_name, data.radius_km,
data.beschreibung, data.dog_id))
return {"ok": True, "id": existing["id"]}
else:
cur = conn.execute("""
INSERT INTO playdate_listings
(dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
VALUES (?,?,?,?,?,?,?)
""", (data.dog_id, uid, data.lat, data.lon, data.ort_name,
data.radius_km, data.beschreibung))
return {"ok": True, "id": cur.lastrowid}
@router.delete("/listing/{dog_id}", status_code=200)
async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
(dog_id, uid)
).fetchone()
if not row:
raise HTTPException(404, "Inserat nicht gefunden.")
conn.execute(
"UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
(dog_id,)
)
return {"ok": True}
@router.get("/my-listing/{dog_id}")
async def my_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"""SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
FROM playdate_listings WHERE dog_id=? AND user_id=?""",
(dog_id, uid)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/request", status_code=201)
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
own_dog = conn.execute(
"SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
(uid,)
).fetchone()
if not own_dog:
raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
from_dog_id = own_dog["id"]
# Zielhund + Besitzer prüfen
target = conn.execute(
"SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
(data.to_dog_id,)
).fetchone()
if not target:
raise HTTPException(404, "Zielhund nicht gefunden.")
if target["user_id"] == uid:
raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
to_user_id = target["user_id"]
# Doppelte Anfrage verhindern
existing = conn.execute(
"SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
(from_dog_id, data.to_dog_id)
).fetchone()
if existing:
if existing["status"] == "pending":
raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
# Alte abgelehnte Anfrage: löschen und neu anlegen
conn.execute(
"DELETE FROM playdate_requests WHERE id=?",
(existing["id"],)
)
cur = conn.execute("""
INSERT INTO playdate_requests
(from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
VALUES (?,?,?,?,?)
""", (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
request_id = cur.lastrowid
# Chat-Konversation anlegen (ohne Freundschaftspflicht)
conv_id = _ensure_conversation(conn, uid, to_user_id)
# Erste Nachricht mit Kontext senden
intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
if data.nachricht:
intro += f" Meine Nachricht: {data.nachricht}"
conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text)
VALUES (?,?,?)
""", (conv_id, uid, intro))
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(to_user_id, {
"title": "Playdate-Anfrage",
"body": f"{user['name']} möchte ein Treffen vereinbaren!",
"type": "playdate_request",
"tag": f"playdate-{request_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
@router.get("/requests")
async def list_requests(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
incoming = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.from_user_id,
uf.name AS from_user_name,
df.name AS from_dog_name, df.rasse AS from_dog_rasse,
df.foto_url AS from_dog_foto,
df.geburtstag AS from_dog_geburtstag,
dt.name AS to_dog_name
FROM playdate_requests pr
JOIN users uf ON uf.id = pr.from_user_id
JOIN dogs df ON df.id = pr.from_dog_id
JOIN dogs dt ON dt.id = pr.to_dog_id
WHERE pr.to_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.to_user_id,
ut.name AS to_user_name,
dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
dt.foto_url AS to_dog_foto,
df.name AS from_dog_name
FROM playdate_requests pr
JOIN users ut ON ut.id = pr.to_user_id
JOIN dogs dt ON dt.id = pr.to_dog_id
JOIN dogs df ON df.id = pr.from_dog_id
WHERE pr.from_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
def _enrich(rows, direction):
result = []
for r in rows:
d = dict(r)
d["direction"] = direction
if direction == "incoming":
d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
result.append(d)
return result
return {
"incoming": _enrich(incoming, "incoming"),
"outgoing": _enrich(outgoing, "outgoing"),
}
@router.patch("/requests/{req_id}", status_code=200)
async def patch_request(req_id: int, data: RequestPatch,
user=Depends(get_current_user)):
uid = user["id"]
if data.status not in ("accepted", "declined"):
raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
with db() as conn:
req = conn.execute(
"SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
(req_id, uid)
).fetchone()
if not req:
raise HTTPException(404, "Anfrage nicht gefunden.")
if req["status"] != "pending":
raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
conn.execute(
"UPDATE playdate_requests SET status=? WHERE id=?",
(data.status, req_id)
)
conv_id = None
if data.status == "accepted":
conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
try:
from routes.push import send_push_to_user
verb = "angenommen" if data.status == "accepted" else "abgelehnt"
send_push_to_user(req["from_user_id"], {
"title": f"Playdate {verb}!",
"body": f"{user['name']} hat deine Anfrage {verb}.",
"type": "playdate_response",
"tag": f"playdate-{req_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "conversation_id": conv_id}