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
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""BAN YARO — Tierärzte Routes (user-level, nie löschen)"""
|
||
|
||
import math
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional
|
||
from database import db
|
||
from auth import get_current_user
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class TierarztCreate(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=200)
|
||
strasse: Optional[str] = Field(None, max_length=300)
|
||
plz: Optional[str] = Field(None, max_length=20)
|
||
ort: Optional[str] = Field(None, max_length=200)
|
||
telefon: Optional[str] = Field(None, max_length=30)
|
||
notfall_telefon: Optional[str] = Field(None, max_length=30)
|
||
email: Optional[str] = Field(None, max_length=254)
|
||
website: Optional[str] = Field(None, max_length=500)
|
||
notizen: Optional[str] = Field(None, max_length=5000)
|
||
ist_notfallpraxis: bool = False
|
||
opening_hours: Optional[str] = Field(None, max_length=500)
|
||
lat: Optional[float] = None
|
||
lon: Optional[float] = None
|
||
osm_id: Optional[str] = Field(None, max_length=100)
|
||
|
||
|
||
class BewertungCreate(BaseModel):
|
||
gesamt: int
|
||
wartezeit: Optional[int] = None
|
||
freundlichkeit: Optional[int] = None
|
||
kompetenz: Optional[int] = None
|
||
text: Optional[str] = Field(None, max_length=5000)
|
||
|
||
|
||
class TierarztUpdate(BaseModel):
|
||
name: Optional[str] = Field(None, max_length=200)
|
||
strasse: Optional[str] = Field(None, max_length=300)
|
||
plz: Optional[str] = Field(None, max_length=20)
|
||
ort: Optional[str] = Field(None, max_length=200)
|
||
telefon: Optional[str] = Field(None, max_length=30)
|
||
notfall_telefon: Optional[str] = Field(None, max_length=30)
|
||
email: Optional[str] = Field(None, max_length=254)
|
||
website: Optional[str] = Field(None, max_length=500)
|
||
notizen: Optional[str] = Field(None, max_length=5000)
|
||
ist_notfallpraxis: Optional[bool] = None
|
||
aktiv: Optional[bool] = None
|
||
opening_hours: Optional[str] = Field(None, max_length=500)
|
||
lat: Optional[float] = None
|
||
lon: Optional[float] = None
|
||
osm_id: Optional[str] = Field(None, max_length=100)
|
||
|
||
|
||
def _fmt_opening_hours(raw: str | None) -> str | None:
|
||
"""Wandelt OSM-opening_hours-String in lesbares Deutsch um.
|
||
|
||
Beispiel: "Mo-Fr 08:00-18:00; Sa 09:00-13:00"
|
||
→ "Mo–Fr 08:00–18:00 · Sa 09:00–13:00"
|
||
"""
|
||
if not raw:
|
||
return None
|
||
if raw.strip().lower() == "24/7":
|
||
return "24/7 geöffnet"
|
||
result = raw.replace(" - ", "–").replace("-", "–", 1)
|
||
result = "; ".join(
|
||
part.strip().replace(";", "").replace(",", " · ")
|
||
for part in raw.split(";")
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/my-favorite")
|
||
async def get_my_favorite(user=Depends(get_current_user)):
|
||
"""Favoriten-Tierarzt des Users (oder null)."""
|
||
with db() as conn:
|
||
row = conn.execute(
|
||
"""SELECT t.* FROM tieraerzte t
|
||
JOIN favorite_vets fv ON fv.vet_id = t.id
|
||
WHERE fv.user_id = ?
|
||
LIMIT 1""",
|
||
(user["id"],)
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
return dict(row)
|
||
|
||
|
||
@router.post("/{vet_id}/favorite")
|
||
async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
|
||
"""Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
|
||
with db() as conn:
|
||
vet = conn.execute(
|
||
"SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
|
||
).fetchone()
|
||
if not vet:
|
||
raise HTTPException(404, "Tierarzt nicht gefunden.")
|
||
|
||
existing = conn.execute(
|
||
"SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
|
||
(user["id"], vet_id)
|
||
).fetchone()
|
||
|
||
if existing:
|
||
conn.execute(
|
||
"DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
|
||
(user["id"], vet_id)
|
||
)
|
||
return {"is_favorite": False}
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
|
||
(user["id"], vet_id)
|
||
)
|
||
return {"is_favorite": True}
|
||
|
||
|
||
@router.get("")
|
||
async def list_tieraerzte(user=Depends(get_current_user)):
|
||
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
|
||
(user["id"],)
|
||
).fetchall()
|
||
favs = {r["vet_id"] for r in conn.execute(
|
||
"SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
|
||
).fetchall()}
|
||
result = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["is_favorite"] = r["id"] in favs
|
||
result.append(d)
|
||
return result
|
||
|
||
|
||
@router.get("/osm-nearby")
|
||
async def osm_nearby(
|
||
lat: float = Query(...),
|
||
lon: float = Query(...),
|
||
radius_km: float = Query(5.0),
|
||
user=Depends(get_current_user)
|
||
):
|
||
"""Findet Tierarzt-POIs aus dem OSM-Cache in der Nähe (max. 10 Treffer)."""
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"""SELECT osm_id, name, lat, lon, opening_hours, phone, website
|
||
FROM osm_pois
|
||
WHERE type = 'tierarzt'
|
||
AND lat BETWEEN ? AND ?
|
||
AND lon BETWEEN ? AND ?""",
|
||
(lat - radius_km / 111.0, lat + radius_km / 111.0,
|
||
lon - radius_km / 111.0, lon + radius_km / 111.0)
|
||
).fetchall()
|
||
|
||
def _dist(r):
|
||
dlat = (r["lat"] - lat) * math.pi / 180
|
||
dlon = (r["lon"] - lon) * math.pi / 180
|
||
a = math.sin(dlat/2)**2 + math.cos(lat*math.pi/180) * math.cos(r["lat"]*math.pi/180) * math.sin(dlon/2)**2
|
||
return 6371 * 2 * math.asin(math.sqrt(a))
|
||
|
||
results = []
|
||
for r in rows:
|
||
d = _dist(r)
|
||
if d <= radius_km:
|
||
results.append({
|
||
"osm_id": r["osm_id"],
|
||
"name": r["name"],
|
||
"lat": r["lat"],
|
||
"lon": r["lon"],
|
||
"opening_hours": r["opening_hours"],
|
||
"opening_hours_fmt": _fmt_opening_hours(r["opening_hours"]),
|
||
"phone": r["phone"],
|
||
"website": r["website"],
|
||
"distanz_km": round(d, 2),
|
||
})
|
||
|
||
results.sort(key=lambda x: x["distanz_km"])
|
||
return results[:10]
|
||
|
||
|
||
@router.post("", status_code=201)
|
||
async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)):
|
||
with db() as conn:
|
||
conn.execute(
|
||
"""INSERT INTO tieraerzte
|
||
(user_id, name, strasse, plz, ort, telefon, notfall_telefon,
|
||
email, website, notizen, ist_notfallpraxis,
|
||
opening_hours, lat, lon, osm_id)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(user["id"], data.name, data.strasse, data.plz, data.ort,
|
||
data.telefon, data.notfall_telefon, data.email, data.website,
|
||
data.notizen, int(data.ist_notfallpraxis),
|
||
data.opening_hours, data.lat, data.lon, data.osm_id)
|
||
)
|
||
row = conn.execute(
|
||
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||
(user["id"],)
|
||
).fetchone()
|
||
return dict(row)
|
||
|
||
|
||
@router.patch("/{tierarzt_id}")
|
||
async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
|
||
user=Depends(get_current_user)):
|
||
with db() as conn:
|
||
entry = conn.execute(
|
||
"SELECT id FROM tieraerzte WHERE id=? AND user_id=?",
|
||
(tierarzt_id, user["id"])
|
||
).fetchone()
|
||
if not entry:
|
||
raise HTTPException(404, "Tierarzt nicht gefunden.")
|
||
|
||
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
||
if "ist_notfallpraxis" in updates:
|
||
updates["ist_notfallpraxis"] = int(updates["ist_notfallpraxis"])
|
||
if "aktiv" in updates:
|
||
updates["aktiv"] = int(updates["aktiv"])
|
||
if not updates:
|
||
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
||
return dict(row)
|
||
|
||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||
conn.execute(
|
||
f"UPDATE tieraerzte SET {set_clause} WHERE id=?",
|
||
list(updates.values()) + [tierarzt_id]
|
||
)
|
||
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
||
return dict(row)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# BEWERTUNGEN
|
||
# ------------------------------------------------------------------
|
||
|
||
def _refresh_vet_rating(conn, tierarzt_id: int):
|
||
"""Aktualisiert avg_rating und anz_bewertungen in tieraerzte."""
|
||
row = conn.execute(
|
||
"""SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg
|
||
FROM tierarzt_bewertungen WHERE tierarzt_id=?""",
|
||
(tierarzt_id,)
|
||
).fetchone()
|
||
n = row["n"] or 0
|
||
avg = row["avg"] or 0.0
|
||
conn.execute(
|
||
"UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?",
|
||
(round(avg, 1), n, tierarzt_id)
|
||
)
|
||
|
||
|
||
@router.post("/{tierarzt_id}/bewertung", status_code=201)
|
||
async def create_bewertung(tierarzt_id: int, data: BewertungCreate,
|
||
user=Depends(get_current_user)):
|
||
"""Bewertung abgeben (1×pro User+Tierarzt, UPSERT)."""
|
||
if not (1 <= data.gesamt <= 5):
|
||
raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.")
|
||
for field in ("wartezeit", "freundlichkeit", "kompetenz"):
|
||
val = getattr(data, field)
|
||
if val is not None and not (1 <= val <= 5):
|
||
raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.")
|
||
|
||
text = (data.text or "").strip()[:500] or None
|
||
|
||
with db() as conn:
|
||
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
||
if not vet:
|
||
raise HTTPException(404, "Tierarzt nicht gefunden.")
|
||
|
||
conn.execute(
|
||
"""INSERT INTO tierarzt_bewertungen
|
||
(tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text)
|
||
VALUES (?,?,?,?,?,?,?)
|
||
ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET
|
||
gesamt=excluded.gesamt,
|
||
wartezeit=excluded.wartezeit,
|
||
freundlichkeit=excluded.freundlichkeit,
|
||
kompetenz=excluded.kompetenz,
|
||
text=excluded.text,
|
||
created_at=datetime('now')""",
|
||
(tierarzt_id, user["id"], data.gesamt, data.wartezeit,
|
||
data.freundlichkeit, data.kompetenz, text)
|
||
)
|
||
_refresh_vet_rating(conn, tierarzt_id)
|
||
row = conn.execute(
|
||
"SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)
|
||
).fetchone()
|
||
return dict(row)
|
||
|
||
|
||
@router.get("/{tierarzt_id}/bewertungen")
|
||
async def list_bewertungen(tierarzt_id: int):
|
||
"""Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte."""
|
||
with db() as conn:
|
||
vet = conn.execute(
|
||
"SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?",
|
||
(tierarzt_id,)
|
||
).fetchone()
|
||
if not vet:
|
||
raise HTTPException(404, "Tierarzt nicht gefunden.")
|
||
|
||
# Stern-Verteilung
|
||
verteilung = {}
|
||
for star in range(1, 6):
|
||
r = conn.execute(
|
||
"SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?",
|
||
(tierarzt_id, star)
|
||
).fetchone()
|
||
verteilung[str(star)] = r["n"]
|
||
|
||
# Letzte 5 Kommentare
|
||
kommentare = conn.execute(
|
||
"""SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at
|
||
FROM tierarzt_bewertungen
|
||
WHERE tierarzt_id=? AND text IS NOT NULL AND text != ''
|
||
ORDER BY created_at DESC LIMIT 5""",
|
||
(tierarzt_id,)
|
||
).fetchall()
|
||
|
||
return {
|
||
"avg_rating": vet["avg_rating"] or 0,
|
||
"anz_bewertungen": vet["anz_bewertungen"] or 0,
|
||
"verteilung": verteilung,
|
||
"kommentare": [dict(k) for k in kommentare],
|
||
}
|
||
|
||
|
||
@router.get("/{tierarzt_id}/meine-bewertung")
|
||
async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)):
|
||
"""Eigene Bewertung für einen Tierarzt (oder null)."""
|
||
with db() as conn:
|
||
row = conn.execute(
|
||
"SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?",
|
||
(tierarzt_id, user["id"])
|
||
).fetchone()
|
||
return dict(row) if row else None
|