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.
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
|
|
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}
|