Feature: Vollständige Züchter-Rolle — Antrag, Würfe, Stammbaum, Genetik
Basis-Features (Schritte 1–11): - Züchter-Antrag mit Dokument-Upload, Admin-Prüfung, E-Mail-Benachrichtigungen - Öffentliches Züchter-Profil + Karten-Marker (lila, certificate-Icon) - Wurfverwaltung: Würfe, Welpen, Gewichtsverlauf, Foto-System - Wurfbörse (öffentlich) mit Filtersuche nach Rasse/Status - Läufigkeits-Tracker: Deckdatum + Wurftermin (+63 Tage, nur für Züchter) - Interessenten-Chat: Kontakt-Button in Wurfbörse und Züchter-Profil - Sidebar-Einträge: Zuchtkartei + Wurfverwaltung für Züchter/Admin Stammbaum & Genetik (Schritte 1–8): - Zuchtkartei: Hunde-Stammdaten mit Vater/Mutter-Verknüpfung - Stammbaum-Visualisierung: 4 Generationen, horizontales CSS-Grid - Gesundheitstests (HD, ED, OCD, Augen…) mit farbigen Ergebnis-Badges - Genetische Tests (MDR1, PRA, DM…): clear/carrier/affected - Titel & Auszeichnungen (CAC, CACIB, IPO…) - Probeverpaarung: IK-Berechnung nach Wright + Ampel-Bewertung - Teilen-Link für öffentliche Hunde-Profile - Kaufvertrag: druckbares HTML-Dokument pro Welpe Technisch: 4 neue Route-Dateien, 5 neue Page-Module, 11 neue DB-Tabellen, icons shield-check + certificate + tree-structure im Sprite — SW by-v465, APP_VER 444
This commit is contained in:
parent
58cb2b4ad3
commit
91340be5a3
24 changed files with 6660 additions and 27 deletions
|
|
@ -534,6 +534,40 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
|
|||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/ki/status — lokale LLM-Erreichbarkeit prüfen
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/ki/status")
|
||||
async def ki_status(user=Depends(require_mod)):
|
||||
import httpx
|
||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
|
||||
|
||||
result = {
|
||||
"mode": KI_MODE,
|
||||
"local_url": LOCAL_BASE_URL if KI_MODE != "off" else None,
|
||||
"local_model_config": LOCAL_MODEL,
|
||||
"local_reachable": False,
|
||||
"local_model_loaded": None,
|
||||
"cloud_model": CLOUD_MODEL,
|
||||
"cloud_key_set": bool(ANTHROPIC_KEY),
|
||||
}
|
||||
|
||||
if KI_MODE != "off":
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(f"{LOCAL_BASE_URL}/models")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
result["local_reachable"] = True
|
||||
if models:
|
||||
result["local_model_loaded"] = models[0].get("id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/system
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
366
backend/routes/breeder.py
Normal file
366
backend/routes/breeder.py
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
"""BAN YARO — Züchter-Verwaltung (Antrag, Admin-Prüfung)"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from database import db
|
||||
from auth import get_current_user, require_premium
|
||||
from mailer import send_email
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TZ = ZoneInfo("Europe/Berlin")
|
||||
|
||||
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
|
||||
os.makedirs(BREEDER_DOCS_DIR, exist_ok=True)
|
||||
|
||||
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
|
||||
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
def require_admin(user=Depends(get_current_user)):
|
||||
if user["rolle"] != "admin":
|
||||
raise HTTPException(403, "Nur für Admins.")
|
||||
return user
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/breeder/status — eigener Antragsstatus
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/breeder/status")
|
||||
async def breeder_status(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
profile = None
|
||||
if row["rolle"] == "breeder":
|
||||
profile = conn.execute(
|
||||
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
|
||||
"FROM breeder_profiles WHERE user_id=?",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
return {
|
||||
"rolle": row["rolle"],
|
||||
"breeder_status": row["breeder_status"],
|
||||
"profile": dict(profile) if profile else None,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/breeder/apply — Antrag stellen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/breeder/apply")
|
||||
async def breeder_apply(
|
||||
zwingername: str = Form(...),
|
||||
rasse_text: str = Form(...),
|
||||
verein: str = Form(...),
|
||||
vdh_mitglied: int = Form(0),
|
||||
stadt: str = Form(...),
|
||||
website: str = Form(""),
|
||||
beschreibung: str = Form(""),
|
||||
dokument: UploadFile = File(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
if row["rolle"] == "breeder":
|
||||
raise HTTPException(400, "Du bist bereits verifizierter Züchter.")
|
||||
if row["breeder_status"] == "pending":
|
||||
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
|
||||
|
||||
# Dokument validieren und speichern
|
||||
data = await dokument.read()
|
||||
if len(data) > 10 * 1024 * 1024:
|
||||
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
|
||||
ext = os.path.splitext(dokument.filename or "")[1].lower()
|
||||
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
|
||||
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
|
||||
|
||||
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
|
||||
os.makedirs(user_doc_dir, exist_ok=True)
|
||||
|
||||
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
|
||||
filepath = os.path.join(user_doc_dir, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET breeder_status='pending' WHERE id=?",
|
||||
(user["id"],)
|
||||
)
|
||||
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
|
||||
conn.execute(
|
||||
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET "
|
||||
"zwingername=excluded.zwingername, rasse_text=excluded.rasse_text, "
|
||||
"verein=excluded.verein, vdh_mitglied=excluded.vdh_mitglied, "
|
||||
"stadt=excluded.stadt, website=excluded.website, beschreibung=excluded.beschreibung, "
|
||||
"verified_at=NULL",
|
||||
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
|
||||
(user["id"], "antrag", filepath)
|
||||
)
|
||||
|
||||
# Admin benachrichtigen
|
||||
admin_html = f"""
|
||||
<h2>Neuer Züchter-Antrag</h2>
|
||||
<p><b>Von:</b> {user['name']} ({user['email']})</p>
|
||||
<p><b>Zwingername:</b> {zwingername}</p>
|
||||
<p><b>Rasse:</b> {rasse_text}</p>
|
||||
<p><b>Verein:</b> {verein}</p>
|
||||
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
|
||||
<p><b>Stadt:</b> {stadt}</p>
|
||||
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
|
||||
"""
|
||||
try:
|
||||
await send_email(
|
||||
ADMIN_EMAIL,
|
||||
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
||||
admin_html,
|
||||
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Admin-Mail nicht gesendet: {e}")
|
||||
|
||||
return {"message": "Antrag eingereicht. Du wirst per E-Mail benachrichtigt."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/breeders/pending — offene Anträge
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/admin/breeders/pending")
|
||||
async def admin_pending_breeders(admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.name, u.email, u.created_at, u.breeder_status,
|
||||
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
||||
bp.stadt, bp.website, bp.beschreibung, bp.created_at AS antrag_at,
|
||||
(SELECT COUNT(*) FROM breeder_documents WHERE user_id=u.id) AS dok_count
|
||||
FROM users u
|
||||
JOIN breeder_profiles bp ON bp.user_id = u.id
|
||||
WHERE u.breeder_status = 'pending'
|
||||
ORDER BY bp.created_at ASC
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/admin/breeder/{user_id}/documents")
|
||||
async def admin_breeder_documents(user_id: int, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
docs = conn.execute(
|
||||
"SELECT id, dokument_typ, file_path, uploaded_at FROM breeder_documents WHERE user_id=?",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
return [dict(d) for d in docs]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/breeder/{user_id}/document/{doc_id} — Datei herunterladen
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/admin/breeder/{user_id}/document/{doc_id}")
|
||||
async def admin_download_document(user_id: int, doc_id: int, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT file_path FROM breeder_documents WHERE id=? AND user_id=?",
|
||||
(doc_id, user_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Dokument nicht gefunden.")
|
||||
path = row["file_path"]
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(404, "Datei nicht auf Datenträger.")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
class RejectBody(BaseModel):
|
||||
grund: str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/admin/breeder/{user_id}/approve — Freischalten
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/admin/breeder/{user_id}/approve")
|
||||
async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
user = conn.execute(
|
||||
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if not user:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
if user["breeder_status"] != "pending":
|
||||
raise HTTPException(400, "Kein offener Antrag.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?",
|
||||
(user_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE breeder_profiles SET verified_at=datetime('now') WHERE user_id=?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
# Bestätigungs-Mail
|
||||
html = f"""
|
||||
<h2>Willkommen als Züchter bei Banyaro!</h2>
|
||||
<p>Hallo {user['name']},</p>
|
||||
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
|
||||
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
|
||||
<p><a href="{APP_URL}">Zur App</a></p>
|
||||
"""
|
||||
try:
|
||||
await send_email(
|
||||
user["email"],
|
||||
"Willkommen als Züchter bei Banyaro!",
|
||||
html,
|
||||
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Bestätigungs-Mail nicht gesendet: {e}")
|
||||
|
||||
return {"message": f"{user['name']} als Züchter freigeschaltet."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/admin/breeder/{user_id}/reject — Ablehnen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/admin/breeder/{user_id}/reject")
|
||||
async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
user = conn.execute(
|
||||
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if not user:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
if user["breeder_status"] != "pending":
|
||||
raise HTTPException(400, "Kein offener Antrag.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET breeder_status='rejected' WHERE id=?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
# Ablehnungs-Mail
|
||||
html = f"""
|
||||
<h2>Dein Züchter-Antrag bei Banyaro</h2>
|
||||
<p>Hallo {user['name']},</p>
|
||||
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
|
||||
<p><b>Grund:</b> {body.grund}</p>
|
||||
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
|
||||
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
|
||||
"""
|
||||
try:
|
||||
await send_email(
|
||||
user["email"],
|
||||
"Dein Züchter-Antrag bei Banyaro",
|
||||
html,
|
||||
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ablehnungs-Mail nicht gesendet: {e}")
|
||||
|
||||
return {"message": f"Antrag von {user['name']} abgelehnt."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/breeder/profil/{zwingername} — öffentliches Profil
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/breeder/profil/{zwingername}")
|
||||
async def breeder_public_profile(zwingername: str):
|
||||
with db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
||||
bp.stadt, bp.website, bp.beschreibung,
|
||||
bp.location_lat, bp.location_lng, bp.verified_at, bp.created_at,
|
||||
u.id AS zuechter_user_id,
|
||||
u.name AS zuechter_name
|
||||
FROM breeder_profiles bp
|
||||
JOIN users u ON u.id = bp.user_id
|
||||
WHERE LOWER(bp.zwingername) = LOWER(?)
|
||||
AND u.rolle = 'breeder'
|
||||
AND u.breeder_status = 'approved'
|
||||
""", (zwingername,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Züchter nicht gefunden.")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/breeder/profile — eigenes Profil bearbeiten
|
||||
# ------------------------------------------------------------------
|
||||
class BreederProfileUpdate(BaseModel):
|
||||
zwingername: Optional[str] = None
|
||||
rasse_text: Optional[str] = None
|
||||
verein: Optional[str] = None
|
||||
vdh_mitglied: Optional[int] = None
|
||||
stadt: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
beschreibung: Optional[str] = None
|
||||
|
||||
@router.put("/breeder/profile")
|
||||
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):
|
||||
with db() as conn:
|
||||
profile = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
if not profile:
|
||||
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
return {"message": "Keine Änderungen."}
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(
|
||||
f"UPDATE breeder_profiles SET {set_clause} WHERE id=?",
|
||||
(*fields.values(), profile["id"])
|
||||
)
|
||||
return {"message": "Profil aktualisiert."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/breeder/map — alle Züchter für Karte
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/breeder/map")
|
||||
async def breeder_map_markers():
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.stadt,
|
||||
bp.location_lat, bp.location_lng
|
||||
FROM breeder_profiles bp
|
||||
JOIN users u ON u.id = bp.user_id
|
||||
WHERE bp.verified_at IS NOT NULL
|
||||
AND u.rolle = 'breeder'
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
356
backend/routes/breeder_photos.py
Normal file
356
backend/routes/breeder_photos.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"""BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import os, logging
|
||||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
from media_utils import validate_upload, generate_preview
|
||||
import uuid
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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 Züchter.")
|
||||
return user
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Modelle
|
||||
# ------------------------------------------------------------------
|
||||
class VisibilityBody(BaseModel):
|
||||
visibility: str
|
||||
|
||||
class CaptionBody(BaseModel):
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktion: Züchter-Profil für User laden
|
||||
# ------------------------------------------------------------------
|
||||
def _get_breeder_profile(conn, user_id: int):
|
||||
row = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Züchter-Profil nicht gefunden.")
|
||||
return row["id"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/breeder/photos/upload — Foto hochladen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/breeder/photos/upload")
|
||||
async def upload_photo(
|
||||
entity_type: str = Form(...),
|
||||
entity_id: int = Form(...),
|
||||
visibility: str = Form("public"),
|
||||
caption: str = Form(""),
|
||||
is_primary: int = Form(0),
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(_require_breeder),
|
||||
):
|
||||
if entity_type not in _VALID_ENTITY_TYPES:
|
||||
raise HTTPException(400, f"Ungültiger entity_type. Erlaubt: {', '.join(_VALID_ENTITY_TYPES)}")
|
||||
|
||||
if visibility not in ("public", "inquiry", "private"):
|
||||
raise HTTPException(400, "Ungültige Sichtbarkeit.")
|
||||
|
||||
raw_data = await file.read()
|
||||
filename = file.filename or "upload.jpg"
|
||||
|
||||
try:
|
||||
validate_upload(raw_data, filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower() or ".jpg"
|
||||
|
||||
with db() as conn:
|
||||
breeder_id = _get_breeder_profile(conn, user["id"])
|
||||
|
||||
# Ownership prüfen (für entity_type != 'breeder')
|
||||
if entity_type == "litter":
|
||||
row = conn.execute(
|
||||
"SELECT id FROM litters WHERE id=? AND breeder_id=?",
|
||||
(entity_id, breeder_id)
|
||||
).fetchone()
|
||||
if not row and user["rolle"] != "admin":
|
||||
raise HTTPException(403, "Kein Zugriff auf diesen Wurf.")
|
||||
elif entity_type == "puppy":
|
||||
row = conn.execute(
|
||||
"""SELECT p.id FROM litter_puppies p
|
||||
JOIN litters l ON l.id=p.litter_id
|
||||
WHERE p.id=? AND l.breeder_id=?""",
|
||||
(entity_id, breeder_id)
|
||||
).fetchone()
|
||||
if not row and user["rolle"] != "admin":
|
||||
raise HTTPException(403, "Kein Zugriff auf diesen Welpen.")
|
||||
elif entity_type == "parent":
|
||||
# parent kann frei hochgeladen werden solange breeder stimmt
|
||||
pass
|
||||
elif entity_type == "breeder":
|
||||
# entity_id muss das eigene Profil sein
|
||||
if entity_id != breeder_id and user["rolle"] != "admin":
|
||||
raise HTTPException(403, "Kein Zugriff auf dieses Züchter-Profil.")
|
||||
|
||||
# Speicherpfad anlegen
|
||||
save_dir = os.path.join(MEDIA_DIR, "breeders", str(breeder_id), entity_type)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
file_uuid = str(uuid.uuid4())
|
||||
file_path = os.path.join(save_dir, f"{file_uuid}.webp")
|
||||
|
||||
# Thumbnail erzeugen
|
||||
thumb_bytes = generate_preview(raw_data, ext)
|
||||
thumb_path = None
|
||||
if thumb_bytes:
|
||||
thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp")
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(thumb_bytes)
|
||||
|
||||
# Originalbild konvertieren und speichern
|
||||
# generate_preview liefert WebP, für das Original nehmen wir Pillow direkt
|
||||
try:
|
||||
import io
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(io.BytesIO(raw_data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
img.save(file_path, format="WEBP", quality=85)
|
||||
except Exception:
|
||||
# Fallback: Rohdaten speichern
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(raw_data)
|
||||
|
||||
# Relative Pfade für DB (relativ zu MEDIA_DIR)
|
||||
rel_file = os.path.relpath(file_path, MEDIA_DIR)
|
||||
rel_thumb = os.path.relpath(thumb_path, MEDIA_DIR) if thumb_path else None
|
||||
|
||||
# Falls is_primary: alle anderen auf 0 setzen
|
||||
if is_primary:
|
||||
conn.execute(
|
||||
"UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?",
|
||||
(breeder_id, entity_type, entity_id)
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO breeder_photos
|
||||
(breeder_id, entity_type, entity_id, file_path, thumbnail_path,
|
||||
caption, is_primary, visibility, sort_order, uploaded_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,
|
||||
(SELECT COALESCE(MAX(sort_order),0)+1 FROM breeder_photos
|
||||
WHERE breeder_id=? AND entity_type=? AND entity_id=?),
|
||||
datetime('now'))""",
|
||||
(breeder_id, entity_type, entity_id, rel_file, rel_thumb,
|
||||
caption.strip() or None, 1 if is_primary else 0,
|
||||
visibility,
|
||||
breeder_id, entity_type, entity_id)
|
||||
)
|
||||
photo_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
photo = conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE id=?", (photo_id,)
|
||||
).fetchone()
|
||||
|
||||
return _photo_dict(photo)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/photos/{entity_type}/{entity_id} — Fotos abrufen
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/photos/{entity_type}/{entity_id}")
|
||||
async def get_photos(
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
user=Depends(get_current_user_optional),
|
||||
):
|
||||
if entity_type not in _VALID_ENTITY_TYPES:
|
||||
raise HTTPException(400, f"Ungültiger entity_type.")
|
||||
|
||||
with db() as conn:
|
||||
# Prüfen ob anfragender User Besitzer oder Admin ist
|
||||
is_owner = False
|
||||
if user:
|
||||
if user["rolle"] == "admin":
|
||||
is_owner = True
|
||||
else:
|
||||
bp = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
if bp:
|
||||
# Besitzer wenn entity dem Züchter gehört
|
||||
if entity_type == "breeder":
|
||||
is_owner = (bp["id"] == entity_id)
|
||||
elif entity_type == "litter":
|
||||
row = conn.execute(
|
||||
"SELECT id FROM litters WHERE id=? AND breeder_id=?",
|
||||
(entity_id, bp["id"])
|
||||
).fetchone()
|
||||
is_owner = bool(row)
|
||||
elif entity_type == "puppy":
|
||||
row = conn.execute(
|
||||
"""SELECT p.id FROM litter_puppies p
|
||||
JOIN litters l ON l.id=p.litter_id
|
||||
WHERE p.id=? AND l.breeder_id=?""",
|
||||
(entity_id, bp["id"])
|
||||
).fetchone()
|
||||
is_owner = bool(row)
|
||||
elif entity_type == "parent":
|
||||
row = conn.execute(
|
||||
"SELECT id FROM breeder_photos WHERE entity_type='parent' AND entity_id=? AND breeder_id=?",
|
||||
(entity_id, bp["id"])
|
||||
).fetchone()
|
||||
is_owner = bool(row)
|
||||
|
||||
if is_owner:
|
||||
photos = conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? ORDER BY sort_order, id",
|
||||
(entity_type, entity_id)
|
||||
).fetchall()
|
||||
else:
|
||||
photos = conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? AND visibility='public' ORDER BY sort_order, id",
|
||||
(entity_type, entity_id)
|
||||
).fetchall()
|
||||
|
||||
return [_photo_dict(p) for p in photos]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/breeder/photos/{id}/visibility
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/breeder/photos/{photo_id}/visibility")
|
||||
async def update_visibility(
|
||||
photo_id: int,
|
||||
body: VisibilityBody,
|
||||
user=Depends(_require_breeder),
|
||||
):
|
||||
if body.visibility not in ("public", "inquiry", "private"):
|
||||
raise HTTPException(400, "Ungültige Sichtbarkeit.")
|
||||
|
||||
with db() as conn:
|
||||
photo = _get_own_photo(conn, photo_id, user)
|
||||
conn.execute(
|
||||
"UPDATE breeder_photos SET visibility=? WHERE id=?",
|
||||
(body.visibility, photo_id)
|
||||
)
|
||||
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
|
||||
return _photo_dict(updated)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/breeder/photos/{id}/primary
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/breeder/photos/{photo_id}/primary")
|
||||
async def set_primary(
|
||||
photo_id: int,
|
||||
user=Depends(_require_breeder),
|
||||
):
|
||||
with db() as conn:
|
||||
photo = _get_own_photo(conn, photo_id, user)
|
||||
# Alle anderen auf 0
|
||||
conn.execute(
|
||||
"UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?",
|
||||
(photo["breeder_id"], photo["entity_type"], photo["entity_id"])
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE breeder_photos SET is_primary=1 WHERE id=?", (photo_id,)
|
||||
)
|
||||
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
|
||||
return _photo_dict(updated)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/breeder/photos/{id}/caption
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/breeder/photos/{photo_id}/caption")
|
||||
async def update_caption(
|
||||
photo_id: int,
|
||||
body: CaptionBody,
|
||||
user=Depends(_require_breeder),
|
||||
):
|
||||
with db() as conn:
|
||||
_get_own_photo(conn, photo_id, user)
|
||||
conn.execute(
|
||||
"UPDATE breeder_photos SET caption=? WHERE id=?",
|
||||
(body.caption, photo_id)
|
||||
)
|
||||
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
|
||||
return _photo_dict(updated)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/breeder/photos/{id}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/breeder/photos/{photo_id}")
|
||||
async def delete_photo(
|
||||
photo_id: int,
|
||||
user=Depends(_require_breeder),
|
||||
):
|
||||
with db() as conn:
|
||||
photo = _get_own_photo(conn, photo_id, user)
|
||||
|
||||
# Dateien löschen
|
||||
for rel in (photo["file_path"], photo["thumbnail_path"]):
|
||||
if rel:
|
||||
abs_path = os.path.join(MEDIA_DIR, rel)
|
||||
try:
|
||||
if os.path.isfile(abs_path):
|
||||
os.unlink(abs_path)
|
||||
except OSError as e:
|
||||
logger.warning("Konnte Datei nicht löschen: %s — %s", abs_path, e)
|
||||
|
||||
conn.execute("DELETE FROM breeder_photos WHERE id=?", (photo_id,))
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# ------------------------------------------------------------------
|
||||
def _get_own_photo(conn, photo_id: int, user: dict):
|
||||
"""Lädt das Foto und prüft Ownership. Wirft 403/404 bei Fehler."""
|
||||
photo = conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE id=?", (photo_id,)
|
||||
).fetchone()
|
||||
if not photo:
|
||||
raise HTTPException(404, "Foto nicht gefunden.")
|
||||
|
||||
if user["rolle"] == "admin":
|
||||
return photo
|
||||
|
||||
# Prüfe ob Züchter-Profil dem User gehört
|
||||
bp = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
if not bp or bp["id"] != photo["breeder_id"]:
|
||||
raise HTTPException(403, "Kein Zugriff auf dieses Foto.")
|
||||
return photo
|
||||
|
||||
|
||||
def _photo_dict(row) -> dict:
|
||||
"""Konvertiert DB-Zeile in API-Response-Dict mit öffentlichen URLs."""
|
||||
if row is None:
|
||||
return {}
|
||||
d = dict(row)
|
||||
# Öffentliche URLs ableiten
|
||||
if d.get("file_path"):
|
||||
d["url"] = "/media/" + d["file_path"].replace("\\", "/")
|
||||
else:
|
||||
d["url"] = None
|
||||
if d.get("thumbnail_path"):
|
||||
d["thumbnail_url"] = "/media/" + d["thumbnail_path"].replace("\\", "/")
|
||||
else:
|
||||
d["thumbnail_url"] = d.get("url")
|
||||
return d
|
||||
|
|
@ -48,6 +48,9 @@ class HealthCreate(BaseModel):
|
|||
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
|
||||
# Tierarzt-Verknüpfung
|
||||
tierarzt_id: Optional[int] = None
|
||||
# Züchter
|
||||
deckdatum: Optional[str] = None
|
||||
wurftermin: Optional[str] = None
|
||||
|
||||
|
||||
class HealthUpdate(BaseModel):
|
||||
|
|
@ -70,6 +73,8 @@ class HealthUpdate(BaseModel):
|
|||
erinnerung: Optional[int] = None
|
||||
intervall_tage: Optional[int] = None
|
||||
tierarzt_id: Optional[int] = None
|
||||
deckdatum: Optional[str] = None
|
||||
wurftermin: Optional[str] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -159,13 +164,15 @@ async def create_health(dog_id: int, data: HealthCreate,
|
|||
(dog_id, typ, bezeichnung, datum, naechstes, notiz,
|
||||
wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
|
||||
dosierung, haeufigkeit, aktiv, bis_datum,
|
||||
schweregrad, reaktion, erinnerung, tierarzt_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
schweregrad, reaktion, erinnerung, tierarzt_id,
|
||||
deckdatum, wurftermin)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
|
||||
data.notiz, data.wert, data.einheit, data.charge_nr,
|
||||
data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
|
||||
data.haeufigkeit, data.aktiv, data.bis_datum,
|
||||
data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id)
|
||||
data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id,
|
||||
data.deckdatum, data.wurftermin)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
||||
|
|
|
|||
575
backend/routes/litters.py
Normal file
575
backend/routes/litters.py
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"""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
|
||||
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):
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
geburt_datum: Optional[str] = None # YYYY-MM-DD
|
||||
erwartetes_datum: Optional[str] = None # YYYY-MM-DD
|
||||
welpen_gesamt: Optional[int] = None
|
||||
welpen_verfuegbar: Optional[int] = None
|
||||
beschreibung: Optional[str] = None
|
||||
gesundheitstests: Optional[str] = None
|
||||
preis_spanne: Optional[str] = None
|
||||
status: str = "geplant" # geplant|geboren|verfuegbar|abgeschlossen
|
||||
sichtbar: int = 0
|
||||
sichtbar_bis: Optional[str] = None
|
||||
|
||||
|
||||
class LitterUpdate(BaseModel):
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
geburt_datum: Optional[str] = None
|
||||
erwartetes_datum: Optional[str] = None
|
||||
welpen_gesamt: Optional[int] = None
|
||||
welpen_verfuegbar: Optional[int] = None
|
||||
beschreibung: Optional[str] = None
|
||||
gesundheitstests: Optional[str] = None
|
||||
preis_spanne: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
sichtbar: Optional[int] = None
|
||||
sichtbar_bis: Optional[str] = None
|
||||
|
||||
|
||||
class PuppyCreate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
geschlecht: Optional[str] = None # maennlich|weiblich
|
||||
farbe: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
geburtsgewicht: Optional[float] = None # Gramm
|
||||
status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben
|
||||
status_sichtbar: int = 1
|
||||
notiz: Optional[str] = None
|
||||
|
||||
|
||||
class PuppyUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
farbe: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
geburtsgewicht: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
status_sichtbar: Optional[int] = None
|
||||
notiz: Optional[str] = None
|
||||
|
||||
|
||||
class WeightEntry(BaseModel):
|
||||
gewicht_g: float
|
||||
gemessen_am: str # 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, vater_name, mutter_name, geburt_datum, erwartetes_datum,
|
||||
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
|
||||
preis_spanne, status, sichtbar, sichtbar_bis)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
profile["id"],
|
||||
body.vater_name,
|
||||
body.mutter_name,
|
||||
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,
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM litters WHERE id=?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
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 & Unterschrift Verkäufer</p>
|
||||
</div>
|
||||
<div class="signature-line">
|
||||
<hr>
|
||||
<p>Ort, Datum & 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)
|
||||
779
backend/routes/zucht_hunde.py
Normal file
779
backend/routes/zucht_hunde.py
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
"""BAN YARO — Zuchtkartei (Hunde, Gesundheitstests, Gentests, Titel, Stammbaum, IK)"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
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 Züchter.")
|
||||
return user
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktionen: Ownership
|
||||
# ------------------------------------------------------------------
|
||||
def _get_breeder_profile_id(user_id: int, conn) -> Optional[int]:
|
||||
"""Gibt die breeder_profiles.id des Users zurück, oder None."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
def _check_hund_owner(hund_id: int, user: dict, conn) -> dict:
|
||||
"""Gibt den Hund zurück wenn der User Eigentümer oder Admin ist."""
|
||||
row = conn.execute(
|
||||
"""SELECT zh.*, bp.user_id AS owner_user_id
|
||||
FROM zucht_hunde zh
|
||||
LEFT 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 dict(row)
|
||||
|
||||
|
||||
def _check_hund_access(hund_id: int, user: Optional[dict], conn) -> dict:
|
||||
"""Zugriff auf Hund: öffentlich wenn is_public=1, sonst nur Owner/Admin."""
|
||||
row = conn.execute(
|
||||
"""SELECT zh.*, bp.user_id AS owner_user_id
|
||||
FROM zucht_hunde zh
|
||||
LEFT 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.")
|
||||
|
||||
is_owner = user and (
|
||||
user["rolle"] == "admin" or row["owner_user_id"] == user["id"]
|
||||
)
|
||||
|
||||
if not row["is_public"] and not is_owner:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stammbaum-Algorithmus
|
||||
# ------------------------------------------------------------------
|
||||
def _build_tree(conn, hund_id, depth: int):
|
||||
if depth == 0 or hund_id is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["vater"] = _build_tree(conn, d["vater_id"], depth - 1)
|
||||
d["mutter"] = _build_tree(conn, d["mutter_id"], depth - 1)
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inzucht-Koeffizient (Wright's Formel)
|
||||
# ------------------------------------------------------------------
|
||||
def _get_ancestors(conn, hund_id, depth: int, path: list) -> dict:
|
||||
"""Gibt {ancestor_id: [paths]} zurück."""
|
||||
if depth == 0 or hund_id is None:
|
||||
return {}
|
||||
row = conn.execute(
|
||||
"SELECT vater_id, mutter_id, name FROM zucht_hunde WHERE id=?", (hund_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
result = {hund_id: [path]}
|
||||
for parent_id in [row["vater_id"], row["mutter_id"]]:
|
||||
if parent_id:
|
||||
sub = _get_ancestors(conn, parent_id, depth - 1, path + [hund_id])
|
||||
for aid, paths in sub.items():
|
||||
result.setdefault(aid, []).extend(paths)
|
||||
return result
|
||||
|
||||
|
||||
def _calculate_ik(conn, vater_id, mutter_id, generations: int = 8) -> float:
|
||||
fa = _get_ancestors(conn, vater_id, generations, [])
|
||||
ma = _get_ancestors(conn, mutter_id, generations, [])
|
||||
common = set(fa.keys()) & set(ma.keys())
|
||||
ik = 0.0
|
||||
for aid in common:
|
||||
for pf in fa[aid]:
|
||||
for pm in ma[aid]:
|
||||
ik += 0.5 ** (len(pf) + len(pm) + 1)
|
||||
return round(ik * 100, 2)
|
||||
|
||||
|
||||
def _ik_rating(ik: float) -> str:
|
||||
if ik < 2.5:
|
||||
return "optimal"
|
||||
if ik < 6.25:
|
||||
return "akzeptabel"
|
||||
if ik < 12.5:
|
||||
return "erhoeht"
|
||||
return "kritisch"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pydantic-Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class HundCreate(BaseModel):
|
||||
name: str
|
||||
rufname: Optional[str] = None
|
||||
geschlecht: str # maennlich|weiblich
|
||||
geburtsdatum: Optional[str] = None
|
||||
sterbedatum: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
taetowiernummer: Optional[str] = None
|
||||
zuchtbuchnummer: Optional[str] = None
|
||||
farbe: Optional[str] = None
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
zuechter_name: Optional[str] = None
|
||||
eigentuemer_name: Optional[str] = None
|
||||
is_public: int = 1
|
||||
notiz: Optional[str] = None
|
||||
foto_url: Optional[str] = None
|
||||
|
||||
|
||||
class HundUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
rufname: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
geburtsdatum: Optional[str] = None
|
||||
sterbedatum: Optional[str] = None
|
||||
chip_nr: Optional[str] = None
|
||||
taetowiernummer: Optional[str] = None
|
||||
zuchtbuchnummer: Optional[str] = None
|
||||
farbe: Optional[str] = None
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
zuechter_name: Optional[str] = None
|
||||
eigentuemer_name: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
notiz: Optional[str] = None
|
||||
foto_url: Optional[str] = None
|
||||
|
||||
|
||||
class HealthTestCreate(BaseModel):
|
||||
test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom
|
||||
test_name: Optional[str] = None
|
||||
ergebnis: Optional[str] = None
|
||||
untersuch_am: Optional[str] = None
|
||||
gueltig_bis: Optional[str] = None
|
||||
untersucher: Optional[str] = None
|
||||
labor: Optional[str] = None
|
||||
zertifikat_nr: Optional[str] = None
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class HealthTestUpdate(BaseModel):
|
||||
test_typ: Optional[str] = None
|
||||
test_name: Optional[str] = None
|
||||
ergebnis: Optional[str] = None
|
||||
untersuch_am: Optional[str] = None
|
||||
gueltig_bis: Optional[str] = None
|
||||
untersucher: Optional[str] = None
|
||||
labor: Optional[str] = None
|
||||
zertifikat_nr: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
class GeneticTestCreate(BaseModel):
|
||||
marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc.
|
||||
marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft
|
||||
genotyp: Optional[str] = None # +/+|+/-|-/-
|
||||
ergebnis_klasse: Optional[str] = None # clear|carrier|affected
|
||||
getestet_am: Optional[str] = None
|
||||
labor: Optional[str] = None
|
||||
zertifikat_nr: Optional[str] = None
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class GeneticTestUpdate(BaseModel):
|
||||
marker_name: Optional[str] = None
|
||||
marker_kategorie: Optional[str] = None
|
||||
genotyp: Optional[str] = None
|
||||
ergebnis_klasse: Optional[str] = None
|
||||
getestet_am: Optional[str] = None
|
||||
labor: Optional[str] = None
|
||||
zertifikat_nr: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
class TitelCreate(BaseModel):
|
||||
titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom
|
||||
titel_name: str
|
||||
verliehen_am: Optional[str] = None
|
||||
ort: Optional[str] = None
|
||||
richter: Optional[str] = None
|
||||
ausstellung: Optional[str] = None
|
||||
formwert: Optional[str] = None
|
||||
is_public: int = 1
|
||||
|
||||
|
||||
class TitelUpdate(BaseModel):
|
||||
titel_typ: Optional[str] = None
|
||||
titel_name: Optional[str] = None
|
||||
verliehen_am: Optional[str] = None
|
||||
ort: Optional[str] = None
|
||||
richter: Optional[str] = None
|
||||
ausstellung: Optional[str] = None
|
||||
formwert: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
|
||||
|
||||
class TrialMatingBody(BaseModel):
|
||||
vater_id: int
|
||||
mutter_id: int
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# HUNDE CRUD
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde — eigene Hunde
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde")
|
||||
async def list_eigene_hunde(user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
if user["rolle"] == "admin":
|
||||
profile_id = _get_breeder_profile_id(user["id"], conn)
|
||||
if profile_id is None:
|
||||
# Admin ohne Profil sieht alle Hunde
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM zucht_hunde ORDER BY name ASC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
else:
|
||||
profile_id = _get_breeder_profile_id(user["id"], conn)
|
||||
if profile_id is None:
|
||||
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM zucht_hunde WHERE breeder_id=? ORDER BY name ASC",
|
||||
(profile_id,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/zuchthunde — Hund anlegen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zuchthunde", status_code=201)
|
||||
async def create_hund(body: HundCreate, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
profile_id = _get_breeder_profile_id(user["id"], conn)
|
||||
if profile_id is None and user["rolle"] != "admin":
|
||||
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO zucht_hunde
|
||||
(breeder_id, name, rufname, geschlecht, geburtsdatum, sterbedatum,
|
||||
chip_nr, taetowiernummer, zuchtbuchnummer, farbe,
|
||||
vater_id, mutter_id, zuechter_name, eigentuemer_name,
|
||||
is_public, notiz, foto_url)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
profile_id,
|
||||
body.name, body.rufname, body.geschlecht,
|
||||
body.geburtsdatum, body.sterbedatum,
|
||||
body.chip_nr, body.taetowiernummer, body.zuchtbuchnummer,
|
||||
body.farbe, body.vater_id, body.mutter_id,
|
||||
body.zuechter_name, body.eigentuemer_name,
|
||||
body.is_public, body.notiz, body.foto_url,
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM zucht_hunde WHERE id=?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# FIXE ROUTEN vor {id} — Route-Reihenfolge kritisch!
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/zuchthunde/trial-mating — Probeverpaarung / IK-Berechnung
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zuchthunde/trial-mating")
|
||||
async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
vater = conn.execute(
|
||||
"SELECT id, name FROM zucht_hunde WHERE id=?", (body.vater_id,)
|
||||
).fetchone()
|
||||
if not vater:
|
||||
raise HTTPException(404, "Vater nicht gefunden.")
|
||||
|
||||
mutter = conn.execute(
|
||||
"SELECT id, name FROM zucht_hunde WHERE id=?", (body.mutter_id,)
|
||||
).fetchone()
|
||||
if not mutter:
|
||||
raise HTTPException(404, "Mutter nicht gefunden.")
|
||||
|
||||
ik_prozent = _calculate_ik(conn, body.vater_id, body.mutter_id, generations=8)
|
||||
rating = _ik_rating(ik_prozent)
|
||||
|
||||
# Gemeinsame Vorfahren mit Namen ermitteln
|
||||
fa = _get_ancestors(conn, body.vater_id, 8, [])
|
||||
ma = _get_ancestors(conn, body.mutter_id, 8, [])
|
||||
common_ids = set(fa.keys()) & set(ma.keys())
|
||||
|
||||
gemeinsame_vorfahren = []
|
||||
for aid in common_ids:
|
||||
anc = conn.execute(
|
||||
"SELECT id, name FROM zucht_hunde WHERE id=?", (aid,)
|
||||
).fetchone()
|
||||
if not anc:
|
||||
continue
|
||||
# Minimale Pfadlängen für Anzeige
|
||||
min_gen_vater = min(len(p) for p in fa[aid])
|
||||
min_gen_mutter = min(len(p) for p in ma[aid])
|
||||
gemeinsame_vorfahren.append({
|
||||
"id": anc["id"],
|
||||
"name": anc["name"],
|
||||
"gen_vater": min_gen_vater,
|
||||
"gen_mutter": min_gen_mutter,
|
||||
})
|
||||
|
||||
gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"])
|
||||
|
||||
return {
|
||||
"ik_prozent": ik_prozent,
|
||||
"ik_rating": rating,
|
||||
"gemeinsame_vorfahren": gemeinsame_vorfahren,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/zuchthunde/health-tests/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.put("/zuchthunde/health-tests/{tid}")
|
||||
async def update_health_test(tid: int, body: HealthTestUpdate, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
test = conn.execute(
|
||||
"""SELECT ht.*, bp.user_id AS owner_user_id
|
||||
FROM dog_health_tests ht
|
||||
JOIN zucht_hunde zh ON zh.id = ht.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE ht.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not test:
|
||||
raise HTTPException(404, "Gesundheitstest nicht gefunden.")
|
||||
if user["rolle"] != "admin" and test["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(tid)
|
||||
conn.execute(
|
||||
f"UPDATE dog_health_tests SET {', '.join(fields)} WHERE id=?", params
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_health_tests WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/zuchthunde/health-tests/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/zuchthunde/health-tests/{tid}", status_code=204)
|
||||
async def delete_health_test(tid: int, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
test = conn.execute(
|
||||
"""SELECT ht.id, bp.user_id AS owner_user_id
|
||||
FROM dog_health_tests ht
|
||||
JOIN zucht_hunde zh ON zh.id = ht.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE ht.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not test:
|
||||
raise HTTPException(404, "Gesundheitstest nicht gefunden.")
|
||||
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Kein Zugriff.")
|
||||
conn.execute("DELETE FROM dog_health_tests WHERE id=?", (tid,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/zuchthunde/genetic-tests/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.put("/zuchthunde/genetic-tests/{tid}")
|
||||
async def update_genetic_test(tid: int, body: GeneticTestUpdate, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
test = conn.execute(
|
||||
"""SELECT gt.*, bp.user_id AS owner_user_id
|
||||
FROM dog_genetic_tests gt
|
||||
JOIN zucht_hunde zh ON zh.id = gt.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE gt.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not test:
|
||||
raise HTTPException(404, "Gentest nicht gefunden.")
|
||||
if user["rolle"] != "admin" and test["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(tid)
|
||||
conn.execute(
|
||||
f"UPDATE dog_genetic_tests SET {', '.join(fields)} WHERE id=?", params
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_genetic_tests WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/zuchthunde/genetic-tests/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/zuchthunde/genetic-tests/{tid}", status_code=204)
|
||||
async def delete_genetic_test(tid: int, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
test = conn.execute(
|
||||
"""SELECT gt.id, bp.user_id AS owner_user_id
|
||||
FROM dog_genetic_tests gt
|
||||
JOIN zucht_hunde zh ON zh.id = gt.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE gt.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not test:
|
||||
raise HTTPException(404, "Gentest nicht gefunden.")
|
||||
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Kein Zugriff.")
|
||||
conn.execute("DELETE FROM dog_genetic_tests WHERE id=?", (tid,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/zuchthunde/titles/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.put("/zuchthunde/titles/{tid}")
|
||||
async def update_titel(tid: int, body: TitelUpdate, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
titel = conn.execute(
|
||||
"""SELECT dt.*, bp.user_id AS owner_user_id
|
||||
FROM dog_titles dt
|
||||
JOIN zucht_hunde zh ON zh.id = dt.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE dt.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not titel:
|
||||
raise HTTPException(404, "Titel nicht gefunden.")
|
||||
if user["rolle"] != "admin" and titel["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(tid)
|
||||
conn.execute(
|
||||
f"UPDATE dog_titles SET {', '.join(fields)} WHERE id=?", params
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_titles WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/zuchthunde/titles/{tid}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/zuchthunde/titles/{tid}", status_code=204)
|
||||
async def delete_titel(tid: int, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
titel = conn.execute(
|
||||
"""SELECT dt.id, bp.user_id AS owner_user_id
|
||||
FROM dog_titles dt
|
||||
JOIN zucht_hunde zh ON zh.id = dt.hund_id
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE dt.id=?""",
|
||||
(tid,)
|
||||
).fetchone()
|
||||
if not titel:
|
||||
raise HTTPException(404, "Titel nicht gefunden.")
|
||||
if user["rolle"] != "admin" and titel["owner_user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Kein Zugriff.")
|
||||
conn.execute("DELETE FROM dog_titles WHERE id=?", (tid,))
|
||||
return None
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# {id}-ROUTEN — nach den fixen Pfaden!
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde/{id} — Hund-Detail
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde/{hund_id}")
|
||||
async def get_hund(hund_id: int, user=Depends(get_current_user_optional)):
|
||||
with db() as conn:
|
||||
hund = _check_hund_access(hund_id, user, conn)
|
||||
return hund
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/zuchthunde/{id} — bearbeiten
|
||||
# ------------------------------------------------------------------
|
||||
@router.put("/zuchthunde/{hund_id}")
|
||||
async def update_hund(hund_id: int, body: HundUpdate, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
_check_hund_owner(hund_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(hund_id)
|
||||
conn.execute(
|
||||
f"UPDATE zucht_hunde SET {', '.join(fields)} WHERE id=?", params
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/zuchthunde/{id} — löschen (cascade)
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/zuchthunde/{hund_id}", status_code=204)
|
||||
async def delete_hund(hund_id: int, user=Depends(_require_breeder)):
|
||||
with db() as conn:
|
||||
_check_hund_owner(hund_id, user, conn)
|
||||
conn.execute("DELETE FROM dog_health_tests WHERE hund_id=?", (hund_id,))
|
||||
conn.execute("DELETE FROM dog_genetic_tests WHERE hund_id=?", (hund_id,))
|
||||
conn.execute("DELETE FROM dog_titles WHERE hund_id=?", (hund_id,))
|
||||
conn.execute("DELETE FROM zucht_hunde WHERE id=?", (hund_id,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde/{id}/pedigree — Stammbaum
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde/{hund_id}/pedigree")
|
||||
async def get_pedigree(
|
||||
hund_id: int,
|
||||
generations: int = Query(default=4, ge=1, le=8),
|
||||
user=Depends(get_current_user_optional),
|
||||
):
|
||||
with db() as conn:
|
||||
_check_hund_access(hund_id, user, conn)
|
||||
tree = _build_tree(conn, hund_id, generations)
|
||||
if not tree:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
return tree
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# GESUNDHEITSTESTS
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde/{id}/health-tests
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde/{hund_id}/health-tests")
|
||||
async def list_health_tests(hund_id: int, user=Depends(get_current_user_optional)):
|
||||
with db() as conn:
|
||||
_check_hund_access(hund_id, user, conn)
|
||||
|
||||
is_owner = user and (
|
||||
user["rolle"] == "admin"
|
||||
or conn.execute(
|
||||
"""SELECT 1 FROM zucht_hunde zh
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE zh.id=? AND bp.user_id=?""",
|
||||
(hund_id, user["id"])
|
||||
).fetchone() is not None
|
||||
)
|
||||
|
||||
q = "SELECT * FROM dog_health_tests WHERE hund_id=?"
|
||||
params = [hund_id]
|
||||
if not is_owner:
|
||||
q += " AND is_public=1"
|
||||
rows = conn.execute(q + " ORDER BY untersuch_am DESC", params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/zuchthunde/{id}/health-tests
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zuchthunde/{hund_id}/health-tests", status_code=201)
|
||||
async def create_health_test(
|
||||
hund_id: int, body: HealthTestCreate, user=Depends(_require_breeder)
|
||||
):
|
||||
with db() as conn:
|
||||
_check_hund_owner(hund_id, user, conn)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO dog_health_tests
|
||||
(hund_id, test_typ, test_name, ergebnis, untersuch_am, gueltig_bis,
|
||||
untersucher, labor, zertifikat_nr, is_public)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
hund_id, body.test_typ, body.test_name, body.ergebnis,
|
||||
body.untersuch_am, body.gueltig_bis, body.untersucher,
|
||||
body.labor, body.zertifikat_nr, body.is_public,
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_health_tests WHERE id=?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# GENTESTS
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde/{id}/genetic-tests
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde/{hund_id}/genetic-tests")
|
||||
async def list_genetic_tests(hund_id: int, user=Depends(get_current_user_optional)):
|
||||
with db() as conn:
|
||||
_check_hund_access(hund_id, user, conn)
|
||||
|
||||
is_owner = user and (
|
||||
user["rolle"] == "admin"
|
||||
or conn.execute(
|
||||
"""SELECT 1 FROM zucht_hunde zh
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE zh.id=? AND bp.user_id=?""",
|
||||
(hund_id, user["id"])
|
||||
).fetchone() is not None
|
||||
)
|
||||
|
||||
q = "SELECT * FROM dog_genetic_tests WHERE hund_id=?"
|
||||
params = [hund_id]
|
||||
if not is_owner:
|
||||
q += " AND is_public=1"
|
||||
rows = conn.execute(q + " ORDER BY getestet_am DESC", params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/zuchthunde/{id}/genetic-tests
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zuchthunde/{hund_id}/genetic-tests", status_code=201)
|
||||
async def create_genetic_test(
|
||||
hund_id: int, body: GeneticTestCreate, user=Depends(_require_breeder)
|
||||
):
|
||||
with db() as conn:
|
||||
_check_hund_owner(hund_id, user, conn)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO dog_genetic_tests
|
||||
(hund_id, marker_name, marker_kategorie, genotyp, ergebnis_klasse,
|
||||
getestet_am, labor, zertifikat_nr, is_public)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
hund_id, body.marker_name, body.marker_kategorie, body.genotyp,
|
||||
body.ergebnis_klasse, body.getestet_am, body.labor,
|
||||
body.zertifikat_nr, body.is_public,
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_genetic_tests WHERE id=?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# TITEL
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/zuchthunde/{id}/titles
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/zuchthunde/{hund_id}/titles")
|
||||
async def list_titles(hund_id: int, user=Depends(get_current_user_optional)):
|
||||
with db() as conn:
|
||||
_check_hund_access(hund_id, user, conn)
|
||||
|
||||
is_owner = user and (
|
||||
user["rolle"] == "admin"
|
||||
or conn.execute(
|
||||
"""SELECT 1 FROM zucht_hunde zh
|
||||
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
|
||||
WHERE zh.id=? AND bp.user_id=?""",
|
||||
(hund_id, user["id"])
|
||||
).fetchone() is not None
|
||||
)
|
||||
|
||||
q = "SELECT * FROM dog_titles WHERE hund_id=?"
|
||||
params = [hund_id]
|
||||
if not is_owner:
|
||||
q += " AND is_public=1"
|
||||
rows = conn.execute(q + " ORDER BY verliehen_am DESC", params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/zuchthunde/{id}/titles
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zuchthunde/{hund_id}/titles", status_code=201)
|
||||
async def create_titel(
|
||||
hund_id: int, body: TitelCreate, user=Depends(_require_breeder)
|
||||
):
|
||||
with db() as conn:
|
||||
_check_hund_owner(hund_id, user, conn)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO dog_titles
|
||||
(hund_id, titel_typ, titel_name, verliehen_am, ort,
|
||||
richter, ausstellung, formwert, is_public)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
hund_id, body.titel_typ, body.titel_name, body.verliehen_am,
|
||||
body.ort, body.richter, body.ausstellung, body.formwert,
|
||||
body.is_public,
|
||||
)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dog_titles WHERE id=?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
Loading…
Add table
Add a link
Reference in a new issue