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

754 lines
30 KiB
Python

"""BAN YARO — Wurfverwaltung (Züchter: Würfe & Welpen)"""
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
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
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LitterCreate(BaseModel):
wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C …
wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = Field(None, max_length=100)
status: str = Field("geplant", max_length=30)
sichtbar: int = 0
sichtbar_bis: Optional[str] = Field(None, max_length=32)
class LitterUpdate(BaseModel):
wurf_rang: Optional[str] = Field(None, max_length=10)
wurf_name: Optional[str] = Field(None, max_length=200)
vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = Field(None, max_length=100)
status: Optional[str] = Field(None, max_length=30)
sichtbar: Optional[int] = None
sichtbar_bis: Optional[str] = Field(None, max_length=32)
class PuppyCreate(BaseModel):
name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich
farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None # Gramm
status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben
status_sichtbar: int = 1
notiz: Optional[str] = Field(None, max_length=2000)
class PuppyUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = Field(None, max_length=20)
farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None
status: Optional[str] = Field(None, max_length=30)
status_sichtbar: Optional[int] = None
notiz: Optional[str] = Field(None, max_length=2000)
class WeightEntry(BaseModel):
gewicht_g: float
gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD
# ------------------------------------------------------------------
# Hilfsfunktion: Züchter-Profil des Users ermitteln
# ------------------------------------------------------------------
def _get_breeder_profile(user_id: int, conn):
row = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
).fetchone()
return row
def _check_litter_owner(litter_id: int, user, conn):
"""Gibt den Wurf zurück wenn der User Eigentümer oder Admin ist."""
litter = conn.execute(
"SELECT l.*, bp.user_id AS owner_user_id "
"FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id "
"WHERE l.id=?",
(litter_id,)
).fetchone()
if not litter:
raise HTTPException(404, "Wurf nicht gefunden.")
if user["rolle"] != "admin" and litter["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return litter
# ------------------------------------------------------------------
# GET /api/litters — öffentliche Übersicht
# ------------------------------------------------------------------
@router.get("/litters")
async def list_public_litters(
rasse: Optional[str] = None,
status: Optional[str] = None,
):
today = date.today().isoformat()
with db() as conn:
q = """
SELECT l.*,
bp.zwingername, bp.rasse_text, bp.stadt,
bp.user_id AS breeder_user_id,
u.name AS zuechter_name
FROM litters l
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE l.sichtbar = 1
AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= ?)
"""
params = [today]
if status:
q += " AND l.status = ?"
params.append(status)
else:
q += " AND l.status IN ('geplant', 'geboren', 'verfuegbar')"
if rasse:
q += " AND LOWER(bp.rasse_text) LIKE LOWER(?)"
params.append(f"%{rasse}%")
q += " ORDER BY l.created_at DESC"
rows = conn.execute(q, params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/litters/my — eigene Würfe (Züchter)
# ------------------------------------------------------------------
@router.get("/litters/my")
async def my_litters(user=Depends(_require_breeder)):
with db() as conn:
if user["rolle"] == "admin":
# Admin ohne eigenes Profil sieht alle Würfe aller Züchter
profile = _get_breeder_profile(user["id"], conn)
if not profile:
rows = conn.execute(
"SELECT l.*, bp.zwingername FROM litters l "
"JOIN breeder_profiles bp ON bp.id = l.breeder_id "
"ORDER BY l.created_at DESC"
).fetchall()
return [dict(r) for r in rows]
else:
profile = _get_breeder_profile(user["id"], conn)
if not profile:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
rows = conn.execute(
"SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC",
(profile["id"],)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters — neuen Wurf anlegen
# ------------------------------------------------------------------
@router.post("/litters", status_code=201)
async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
with db() as conn:
profile = _get_breeder_profile(user["id"], conn)
if not profile:
raise HTTPException(404, "Züchter-Profil nicht gefunden.")
cur = conn.execute(
"""INSERT INTO litters
(breeder_id, wurf_rang, wurf_name,
vater_name, mutter_name, vater_id, mutter_id,
geburt_datum, erwartetes_datum,
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
preis_spanne, status, sichtbar, sichtbar_bis)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
profile["id"],
body.wurf_rang,
body.wurf_name,
body.vater_name,
body.mutter_name,
body.vater_id,
body.mutter_id,
body.geburt_datum,
body.erwartetes_datum,
body.welpen_gesamt,
body.welpen_verfuegbar,
body.beschreibung,
body.gesundheitstests,
body.preis_spanne,
body.status,
body.sichtbar,
body.sichtbar_bis,
)
)
litter_id = cur.lastrowid
row = conn.execute(
"SELECT * FROM litters WHERE id=?", (litter_id,)
).fetchone()
# Tierschutz-Check
from welfare_check import check_welfare
welfare = check_welfare(
conn, profile["id"],
vater_id=body.vater_id,
mutter_id=body.mutter_id,
)
# Welfare-Level speichern
conn.execute(
"UPDATE litters SET welfare_level=? WHERE id=?",
(welfare["level"], litter_id)
)
result = dict(row)
result["welfare"] = welfare
return result
# ------------------------------------------------------------------
# POST /api/litters/{id}/welfare-confirm — Tierschutz-Hinweis bestätigt
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/welfare-confirm")
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
from mailer import send_email, email_html
import os, logging as _log
_logger = _log.getLogger(__name__)
with db() as conn:
litter = _check_litter_owner(litter_id, user, conn)
conn.execute(
"UPDATE litters SET welfare_acknowledged=1 WHERE id=?", (litter_id,)
)
welfare_level = litter.get("welfare_level", "")
if welfare_level == "critical":
# Admin benachrichtigen
profile = conn.execute(
"SELECT bp.zwingername, u.name, u.email "
"FROM breeder_profiles bp JOIN users u ON u.id=bp.user_id "
"WHERE bp.user_id=?", (user["id"],)
).fetchone()
admin_email = os.getenv("ADMIN_EMAIL", "admin@banyaro.app")
app_url = os.getenv("APP_URL", "https://banyaro.app")
zuechter = profile["name"] if profile else user.get("name", "Unbekannt")
zwinger = profile["zwingername"] if profile else ""
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
import html as _html
welfare_body = f"""
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{_html.escape(zuechter)}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{_html.escape(eltern['vater_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{_html.escape(eltern['mutter_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
</table>"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
)
except Exception as e:
_logger.warning(f"Tierschutz-Admin-Mail fehlgeschlagen: {e}")
return {"message": "Bestätigt."}
# ------------------------------------------------------------------
# GET /api/litters/{id} — Wurf-Detail (öffentlich wenn sichtbar=1)
# ------------------------------------------------------------------
@router.get("/litters/{litter_id}")
async def get_litter(litter_id: int, user=Depends(get_current_user_optional)):
today = date.today().isoformat()
with db() as conn:
row = conn.execute(
"""SELECT l.*,
bp.zwingername, bp.rasse_text, bp.stadt,
bp.user_id AS owner_user_id,
u.name AS zuechter_name
FROM litters l
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE l.id=?""",
(litter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Wurf nicht gefunden.")
is_owner = user and (
user["rolle"] == "admin" or row["owner_user_id"] == user["id"]
)
# Nicht-öffentliche Würfe nur für Züchter/Admin
if not row["sichtbar"] and not is_owner:
raise HTTPException(404, "Wurf nicht gefunden.")
# Abgelaufene Würfe
if row["sichtbar_bis"] and row["sichtbar_bis"] < today and not is_owner:
raise HTTPException(404, "Wurf nicht mehr verfügbar.")
return dict(row)
# ------------------------------------------------------------------
# PUT /api/litters/{id} — Wurf bearbeiten
# ------------------------------------------------------------------
@router.put("/litters/{litter_id}")
async def update_litter(litter_id: int, body: LitterUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(litter_id)
conn.execute(
f"UPDATE litters SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute("SELECT * FROM litters WHERE id=?", (litter_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/litters/{id} — Wurf löschen
# ------------------------------------------------------------------
@router.delete("/litters/{litter_id}", status_code=204)
async def delete_litter(litter_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
conn.execute("DELETE FROM puppy_weights WHERE welpe_id IN (SELECT id FROM puppies WHERE wurf_id=?)", (litter_id,))
conn.execute("DELETE FROM puppies WHERE wurf_id=?", (litter_id,))
conn.execute("DELETE FROM litters WHERE id=?", (litter_id,))
return None
# ------------------------------------------------------------------
# GET /api/litters/{id}/puppies — Welpen eines Wurfs
# ------------------------------------------------------------------
@router.get("/litters/{litter_id}/puppies")
async def list_puppies(litter_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
litter = conn.execute(
"""SELECT l.sichtbar, l.sichtbar_bis, bp.user_id AS owner_user_id
FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE l.id=?""",
(litter_id,)
).fetchone()
if not litter:
raise HTTPException(404, "Wurf nicht gefunden.")
is_owner = user and (
user["rolle"] == "admin" or litter["owner_user_id"] == user["id"]
)
q = "SELECT * FROM puppies WHERE wurf_id=?"
params = [litter_id]
if not is_owner:
q += " AND status_sichtbar=1"
rows = conn.execute(q + " ORDER BY created_at ASC", params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters/{id}/puppies — Welpe anlegen
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/puppies", status_code=201)
async def add_puppy(litter_id: int, body: PuppyCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
cur = conn.execute(
"""INSERT INTO puppies
(wurf_id, name, geschlecht, farbe, chip_nr, geburtsgewicht,
status, status_sichtbar, notiz)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
litter_id,
body.name,
body.geschlecht,
body.farbe,
body.chip_nr,
body.geburtsgewicht,
body.status,
body.status_sichtbar,
body.notiz,
)
)
row = conn.execute(
"SELECT * FROM puppies WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PUT /api/litters/puppies/{id} — Welpe bearbeiten
# ------------------------------------------------------------------
@router.put("/litters/puppies/{puppy_id}")
async def update_puppy(puppy_id: int, body: PuppyUpdate, user=Depends(_require_breeder)):
with db() as conn:
puppy = conn.execute(
"""SELECT p.*, l.id AS litter_id, bp.user_id AS owner_user_id
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(puppy_id)
conn.execute(
f"UPDATE puppies SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute("SELECT * FROM puppies WHERE id=?", (puppy_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/litters/puppies/{id}/weights — Gewichtsverlauf laden
# ------------------------------------------------------------------
@router.get("/litters/puppies/{puppy_id}/weights")
async def get_weights(puppy_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
rows = conn.execute(
"SELECT id, gewicht_g, gemessen_am FROM puppy_weights WHERE welpe_id=? ORDER BY gemessen_am DESC",
(puppy_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters/puppies/{id}/weight — Gewicht erfassen
# ------------------------------------------------------------------
@router.post("/litters/puppies/{puppy_id}/weight", status_code=201)
async def add_weight(puppy_id: int, body: WeightEntry, user=Depends(_require_breeder)):
with db() as conn:
puppy = conn.execute(
"""SELECT p.id, bp.user_id AS owner_user_id
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
cur = conn.execute(
"INSERT INTO puppy_weights (welpe_id, gewicht_g, gemessen_am) VALUES (?,?,?)",
(puppy_id, body.gewicht_g, body.gemessen_am)
)
row = conn.execute(
"SELECT * FROM puppy_weights WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/litters/puppies/{id}/contract — Kaufvertrag als HTML
# ------------------------------------------------------------------
@router.get("/litters/puppies/{puppy_id}/contract")
async def generate_contract(
puppy_id: int,
kaeufer_name: str,
kaeufer_adresse: str,
kaeufer_email: str = "",
preis: str = "",
user=Depends(_require_breeder),
):
with db() as conn:
puppy = conn.execute(
"""SELECT p.*, l.geburt_datum, l.id AS litter_id,
bp.user_id AS owner_user_id,
bp.zwingername, bp.rasse_text, bp.stadt,
u.name AS zuechter_name, u.email AS zuechter_email
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
def esc(s):
if not s:
return ""
return (str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
heute = date.today().strftime("%d.%m.%Y")
geschlecht_label = (
"Rüde" if puppy["geschlecht"] == "maennlich" else
"Hündin" if puppy["geschlecht"] == "weiblich" else ""
)
geburtsdatum = ""
if puppy["geburt_datum"]:
try:
from datetime import date as _date
gd = _date.fromisoformat(puppy["geburt_datum"])
geburtsdatum = gd.strftime("%d.%m.%Y")
except Exception:
geburtsdatum = esc(puppy["geburt_datum"])
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kaufvertrag — {esc(puppy['name'] or 'Welpe')}</title>
<style>
body {{
font-family: Arial, Helvetica, sans-serif;
font-size: 12pt;
margin: 2cm 2.5cm;
color: #111;
}}
h1 {{ font-size: 18pt; text-align: center; margin-bottom: 0.2cm; }}
h2 {{ font-size: 13pt; margin-top: 1.2cm; border-bottom: 1px solid #999; padding-bottom: 3px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 0.4cm; }}
td {{ padding: 4px 8px; vertical-align: top; }}
td:first-child {{ width: 45%; font-weight: bold; color: #444; }}
.section {{ margin-top: 1cm; }}
.signature-block {{ display: flex; gap: 4cm; margin-top: 2cm; }}
.signature-line {{ flex: 1; }}
.signature-line hr {{ border: none; border-top: 1px solid #333; margin-top: 2cm; }}
.signature-line p {{ font-size: 10pt; color: #555; margin: 4px 0 0; }}
@media print {{
.no-print {{ display: none; }}
body {{ margin: 1.5cm 2cm; }}
}}
</style>
</head>
<body>
<p style="text-align:right;font-size:10pt;color:#666">Datum: {heute}</p>
<h1>Kaufvertrag über einen Welpen</h1>
<p style="text-align:center;color:#555;font-size:10pt;margin-top:0">
Rassehund · {esc(puppy['rasse_text'] or '')}
</p>
<h2>Verkäufer (Züchter)</h2>
<table>
<tr><td>Zwingername</td><td>{esc(puppy['zwingername'] or '')}</td></tr>
<tr><td>Name</td><td>{esc(puppy['zuechter_name'] or '')}</td></tr>
<tr><td>Ort</td><td>{esc(puppy['stadt'] or '')}</td></tr>
<tr><td>E-Mail</td><td>{esc(puppy['zuechter_email'] or '')}</td></tr>
</table>
<h2>Käufer</h2>
<table>
<tr><td>Name</td><td>{esc(kaeufer_name)}</td></tr>
<tr><td>Adresse</td><td>{esc(kaeufer_adresse)}</td></tr>
<tr><td>E-Mail</td><td>{esc(kaeufer_email) if kaeufer_email else ''}</td></tr>
</table>
<h2>Welpe</h2>
<table>
<tr><td>Name</td><td>{esc(puppy['name'] or '')}</td></tr>
<tr><td>Geschlecht</td><td>{geschlecht_label}</td></tr>
<tr><td>Rasse</td><td>{esc(puppy['rasse_text'] or '')}</td></tr>
<tr><td>Geburtsdatum</td><td>{geburtsdatum or ''}</td></tr>
<tr><td>Chip-Nr.</td><td>{esc(puppy['chip_nr'] or '')}</td></tr>
<tr><td>Farbe / Fell</td><td>{esc(puppy['farbe'] or '')}</td></tr>
</table>
<h2>Kaufpreis</h2>
<table>
<tr><td>Vereinbarter Preis</td><td><strong>{esc(preis) if preis else ''}</strong></td></tr>
</table>
<div class="section">
<h2>Allgemeine Vereinbarungen</h2>
<p>Der Käufer bestätigt, den Welpen in einem einwandfreien Gesundheitszustand entgegengenommen zu haben.
Der Verkäufer sichert zu, dass der Welpe nach bestem Wissen und Gewissen aufgezogen wurde und die
angegebenen Gesundheitsinformationen der Wahrheit entsprechen. Der Käufer verpflichtet sich, den
Welpen artgerecht zu halten und tierärztlich versorgen zu lassen.</p>
</div>
<div class="signature-block">
<div class="signature-line">
<hr>
<p>Ort, Datum &amp; Unterschrift Verkäufer</p>
</div>
<div class="signature-line">
<hr>
<p>Ort, Datum &amp; Unterschrift Käufer</p>
</div>
</div>
<p class="no-print" style="margin-top:1.5cm;text-align:center">
<button onclick="window.print()"
style="padding:8px 24px;font-size:12pt;cursor:pointer;border:1px solid #333;border-radius:4px;background:#f5f5f5">
Drucken / Als PDF speichern
</button>
</p>
</body>
</html>"""
return HTMLResponse(content=html)
# ------------------------------------------------------------------
# Warteliste
# ------------------------------------------------------------------
class WaitlistEntry(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
email: Optional[str] = Field(None, max_length=254)
telefon: Optional[str] = Field(None, max_length=30)
nachricht: Optional[str] = Field(None, max_length=5000)
wunsch_geschlecht: str = Field("egal", max_length=20)
wunsch_farbe: Optional[str] = Field(None, max_length=100)
prioritaet: int = 0
status: str = Field("anfrage", max_length=30)
notiz: Optional[str] = Field(None, max_length=2000)
class WaitlistUpdate(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
nachricht: Optional[str] = None
wunsch_geschlecht: Optional[str] = None
wunsch_farbe: Optional[str] = None
prioritaet: Optional[int] = None
status: Optional[str] = None
notiz: Optional[str] = None
@router.get("/litters/{litter_id}/waitlist")
async def get_waitlist(litter_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
rows = conn.execute(
"SELECT * FROM litter_waitlist WHERE litter_id=? ORDER BY prioritaet, created_at",
(litter_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/litters/{litter_id}/waitlist", status_code=201)
async def add_waitlist_entry(litter_id: int, body: WaitlistEntry, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
cur = conn.execute(
"""INSERT INTO litter_waitlist
(litter_id, name, email, telefon, nachricht, wunsch_geschlecht, wunsch_farbe,
prioritaet, status, notiz)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(litter_id, body.name, body.email, body.telefon, body.nachricht,
body.wunsch_geschlecht, body.wunsch_farbe, body.prioritaet, body.status, body.notiz)
)
row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/litters/waitlist/{entry_id}")
async def update_waitlist_entry(entry_id: int, body: WaitlistUpdate, user=Depends(_require_breeder)):
with db() as conn:
entry = conn.execute(
"""SELECT w.*, bp.user_id AS owner_user_id FROM litter_waitlist w
JOIN litters l ON l.id = w.litter_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE w.id=?""",
(entry_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if not fields:
return dict(entry)
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE litter_waitlist SET {sets} WHERE id=?", (*fields.values(), entry_id))
row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (entry_id,)).fetchone()
return dict(row)
@router.delete("/litters/waitlist/{entry_id}", status_code=204)
async def delete_waitlist_entry(entry_id: int, user=Depends(_require_breeder)):
with db() as conn:
entry = conn.execute(
"""SELECT w.id, bp.user_id AS owner_user_id FROM litter_waitlist w
JOIN litters l ON l.id = w.litter_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE w.id=?""",
(entry_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
conn.execute("DELETE FROM litter_waitlist WHERE id=?", (entry_id,))