Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)
- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
parent
031c6028ac
commit
742ad189e8
26 changed files with 5734 additions and 27 deletions
364
backend/routes/playdate.py
Normal file
364
backend/routes/playdate.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""BAN YARO — Playdate-Matching"""
|
||||
|
||||
import math
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Haversine
|
||||
# ------------------------------------------------------------------
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
R = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (math.sin(dlat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
|
||||
* math.sin(dlon / 2) ** 2)
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
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(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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue