banyaro/backend/routes/tieraerzte.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
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
2026-05-27 13:40:30 +02:00

336 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"
"MoFr 08:0018:00 · Sa 09:0013: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