banyaro/backend/routes/breeder_photos.py
rene 91340be5a3 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
2026-04-28 18:25:21 +02:00

356 lines
13 KiB
Python

"""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