banyaro/backend/routes/playdate.py
rene 297bd22f96 Bündel 2: Zentrale Helper für DRY-Cleanup, SW by-v1114
NEUE BACKEND-MODULE:

math_utils.py
- haversine_km(lat1, lon1, lat2, lon2) — Distanz in km
- haversine_m(...) — Convenience-Wrapper in Metern
- bbox_deg_from_km(lat, radius_km) — Bounding-Box-Approximation
  für SQL-Vorfilter (statt Haversine im Python-Loop)

config.py
- DB_PATH, MEDIA_DIR, BREEDER_DOCS_DIR, SCANINPUT_DIR
- API_TIMEOUT_SHORT (5s) / DEFAULT (10s) / LONG (30s)
- HTTP_USER_AGENT, HTTP_HEADERS

errors.py
- not_found(msg), forbidden(msg), bad_request(msg), unauthorized(msg)
- conflict(msg), too_many_requests(msg, retry_after), service_unavailable(msg)
- require_or_404(row, msg) — Convenience-Helper

UI.JS ERWEITERUNGEN:

UI.time erweitert:
- formatDate(d)     → "15.03.2026"
- formatDateTime(d) → "15.03.2026, 14:30"
- weekday(d)        → "Di"
- parseISO(str)     → {year, month, day}

UI.text (neu):
- truncate(str, maxLen, ellipsis='…')
- slug(str) — URL-Slug aus String (mit DE-Umlauten)

UI.money (neu):
- format(value) → "12,34 €" (de-DE, EUR)
- formatWithSuffix(value, '/Jahr')

HAVERSINE-MIGRATION (13 Backend-Routen):
alerts.py, services.py, places.py, events.py, diary.py, playdate.py,
lost.py, poison.py, adoption.py, gassi_zeiten.py, sitting.py, routen.py,
walks.py

- Alle lokalen def _haversine/haversine_km entfernt
- Aufrufe ersetzt durch haversine_km/haversine_m je nach Einheit
- from math_utils import haversine_km|haversine_m in jeder Datei

Tests 19/19 grün.

Hinweis: Migrationen für MEDIA_DIR (19 Stellen), API-Timeouts (12),
Date-Formatter im Frontend (24) und UI.text.truncate (5) sind als
Folge-Sprints möglich. Helper sind verfügbar.
2026-05-27 11:19:06 +02:00

351 lines
12 KiB
Python

"""BAN YARO — Playdate-Matching"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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] = None
radius_km: int = 10
beschreibung: Optional[str] = None
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = None
class RequestPatch(BaseModel):
status: str # 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}