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

165 lines
5.8 KiB
Python

"""BAN YARO — Verlorener Hund Routes"""
import os, uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all
from media_utils import convert_media
from math_utils import haversine_m
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str
rasse: Optional[str] = None
beschreibung: str
lat: float
lon: float
dog_id: Optional[int] = None
client_time: Optional[str] = None
# ------------------------------------------------------------------
# GET /api/lost — aktive Meldungen (optional nach Distanz gefiltert)
# ------------------------------------------------------------------
@router.get("")
async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
radius_km: float = 25):
with db() as conn:
rows = conn.execute(
"""SELECT l.*, u.name AS melder_name
FROM lost_dogs l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.is_active = 1
ORDER BY l.created_at DESC"""
).fetchall()
results = []
for r in rows:
entry = dict(r)
if lat is not None and lon is not None:
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000:
continue
entry["distanz_m"] = round(dist)
results.append(entry)
if lat is not None and lon is not None:
results.sort(key=lambda x: x.get("distanz_m", 0))
return results
# ------------------------------------------------------------------
# POST /api/lost — Hund vermisst melden (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn:
ct = safe_client_time(data.client_time)
conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse,
data.beschreibung, data.lat, data.lon, ct)
)
row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
entry = dict(row)
send_push_to_all({
"type": "lost_dog_alert",
"title": f"🔍 {data.name} wird vermisst!",
"body": f"{data.rasse or 'Hund'} in deiner Nähe vermisst. Hilf bei der Suche!",
"tag": f"lost-{entry['id']}",
"data": {"page": "lost"},
})
return entry
# ------------------------------------------------------------------
# POST /api/lost/{id}/foto — Foto hochladen (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/foto")
async def upload_foto(
lost_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM lost_dogs WHERE id=? AND user_id=?",
(lost_id, user["id"])
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "lost", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
foto_url = f"/media/lost/{filename}"
with db() as conn:
conn.execute("UPDATE lost_dogs SET foto_url=? WHERE id=?", (foto_url, lost_id))
return {"foto_url": foto_url}
# ------------------------------------------------------------------
# POST /api/lost/{id}/found — als gefunden markieren (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/found")
async def mark_found(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"""UPDATE lost_dogs
SET is_active=0, gefunden_at=datetime('now')
WHERE id=?""",
(lost_id,)
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/lost/{id} — eigene Meldung löschen (Login)
# ------------------------------------------------------------------
@router.delete("/{lost_id}", status_code=204)
async def delete_lost(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute("DELETE FROM lost_dogs WHERE id=?", (lost_id,))
return None