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
307 lines
12 KiB
Python
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,))
|