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

307 lines
12 KiB
Python

"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date, timedelta
from database import db
from auth import get_current_user
router = APIRouter()
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für verifizierte Züchter.")
return user
def _check_hund_owner(hund_id: int, user: dict, conn):
row = conn.execute(
"""SELECT zh.id, bp.user_id AS owner_user_id FROM zucht_hunde zh
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=?""", (hund_id,)
).fetchone()
if not row:
raise HTTPException(404, "Hund nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
def _check_laeufi_owner(laeufi_id: int, user: dict, conn):
row = conn.execute(
"""SELECT l.id, bp.user_id AS owner_user_id FROM laeufi_log l
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE l.id=?""", (laeufi_id,)
).fetchone()
if not row:
raise HTTPException(404, "Läufigkeit nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
# ------------------------------------------------------------------
# Meilensteine berechnen
# ------------------------------------------------------------------
_MEILENSTEINE = [
(21, "Frühester Ultraschall möglich"),
(25, "Ultraschall empfohlen (Welpen erkennbar)"),
(35, "Bauch wird sichtbar"),
(45, "Röntgen möglich (Skelettanzahl)"),
(56, "Wurfbox aufstellen"),
(58, "Tägliche Temperaturmessung beginnen"),
(63, "Erwarteter Geburtstermin"),
(65, "Tierarzt konsultieren wenn keine Geburt"),
]
def _calc_meilensteine(deckdatum_str: str) -> list:
try:
deck = date.fromisoformat(deckdatum_str)
except Exception:
return []
return [
{
"tag": tag,
"datum": (deck + timedelta(days=tag)).isoformat(),
"label": label,
"vorbei": (deck + timedelta(days=tag)) < date.today(),
}
for tag, label in _MEILENSTEINE
]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LaeufiCreate(BaseModel):
beginn: str = Field(..., max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class LaeufiUpdate(BaseModel):
beginn: Optional[str] = Field(None, max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class ProgestCreate(BaseModel):
datum: str = Field(..., max_length=32)
wert: Optional[float] = None
einheit: str = Field("ng/ml", max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
class ProgestUpdate(BaseModel):
datum: Optional[str] = Field(None, max_length=32)
wert: Optional[float] = None
einheit: Optional[str] = Field(None, max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
class DeckCreate(BaseModel):
deckdatum: str = Field(..., max_length=32)
laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: str = Field("natuerlich", max_length=50)
traechtig: int = 0
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class DeckUpdate(BaseModel):
deckdatum: Optional[str] = Field(None, max_length=32)
ruede_id: Optional[int] = None
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: Optional[str] = Field(None, max_length=50)
traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------
# Läufigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/{hund_id}")
async def list_laeufi(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM laeufi_log WHERE hund_id=? ORDER BY beginn DESC",
(hund_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/{hund_id}", status_code=201)
async def add_laeufi(hund_id: int, body: LaeufiCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"INSERT INTO laeufi_log (hund_id, beginn, ende, notiz) VALUES (?,?,?,?)",
(hund_id, body.beginn, body.ende, body.notiz)
)
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/entry/{laeufi_id}")
async def update_laeufi(laeufi_id: int, body: LaeufiUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE laeufi_log SET {sets} WHERE id=?", (*fields.values(), laeufi_id))
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (laeufi_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/entry/{laeufi_id}", status_code=204)
async def delete_laeufi(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
conn.execute("DELETE FROM laeufi_log WHERE id=?", (laeufi_id,))
# ------------------------------------------------------------------
# Progesterontests
# ------------------------------------------------------------------
@router.get("/laeufi/entry/{laeufi_id}/prog")
async def list_prog(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
rows = conn.execute(
"SELECT * FROM progesteron_tests WHERE laeufi_id=? ORDER BY datum",
(laeufi_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/entry/{laeufi_id}/prog", status_code=201)
async def add_prog(laeufi_id: int, body: ProgestCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
cur = conn.execute(
"INSERT INTO progesteron_tests (laeufi_id, datum, wert, einheit, labor, notiz) VALUES (?,?,?,?,?,?)",
(laeufi_id, body.datum, body.wert, body.einheit, body.labor, body.notiz)
)
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/prog/{prog_id}")
async def update_prog(prog_id: int, body: ProgestUpdate, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE progesteron_tests SET {sets} WHERE id=?", (*fields.values(), prog_id))
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (prog_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/prog/{prog_id}", status_code=204)
async def delete_prog(prog_id: int, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM progesteron_tests WHERE id=?", (prog_id,))
# ------------------------------------------------------------------
# Deckdaten & Trächtigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/deck/{hund_id}")
async def list_deck(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM deckdaten WHERE hund_id=? ORDER BY deckdatum DESC",
(hund_id,)
).fetchall()
result = []
for r in rows:
d = dict(r)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
result.append(d)
return result
@router.post("/laeufi/deck/{hund_id}", status_code=201)
async def add_deck(hund_id: int, body: DeckCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO deckdaten
(hund_id, laeufi_id, deckdatum, ruede_id, ruede_name, deckart,
traechtig, ultraschall_datum, notiz)
VALUES (?,?,?,?,?,?,?,?,?)""",
(hund_id, body.laeufi_id, body.deckdatum, body.ruede_id, body.ruede_name,
body.deckart, body.traechtig, body.ultraschall_datum, body.notiz)
)
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (cur.lastrowid,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.put("/laeufi/deck/entry/{deck_id}")
async def update_deck(deck_id: int, body: DeckUpdate, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE deckdaten SET {sets} WHERE id=?", (*fields.values(), deck_id))
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (deck_id,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.delete("/laeufi/deck/entry/{deck_id}", status_code=204)
async def delete_deck(deck_id: int, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM deckdaten WHERE id=?", (deck_id,))