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
351 lines
12 KiB
Python
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}
|