banyaro/backend/routes/breeder_photos.py
rene 5f01abc590 Züchter-Editor: Wurfnamen sichtbar, 'undefined Medien' gefixt, Mitgliedschaften & Zertifikate
Rene: 'Züchter sollten mehr Einfluss haben — Wurfnamen (B-Wurf), Mitglied-
schaften und Zertifikate fürs Profil.'

- Wurfnamen: Infrastruktur existierte komplett (wurf_rang/wurf_name in DB,
  Backend, Wurfverwaltungs-Formular) — war nur im Editor und auf der
  öffentlichen Seite unsichtbar. Editor-Karten zeigen jetzt 'B-Wurf · Name'
  (Feldname-Bug geburtsdatum→geburt_datum), öffentliche Wurf-Karten bekommen
  den Rang/Namen als Badge, Hinweis im Editor verlinkt zur Vergabe.
- 'undefined Medien': my-editor lieferte kein foto_count (+photos fürs
  Profil-Grid) — ergänzt.
- NEU Mitgliedschaften & Zertifikate: entity_type 'certificate' im Foto-
  System (Ownership wie breeder), Editor-Sektion mit Upload (Bezeichnung als
  Caption) + Löschen, öffentliche Profilseite zeigt eigene Sektion mit
  Logos/Urkunden (klickbar, lazy). Public-Endpoint liefert result.zertifikate.

Tests: my-editor inkl. Wurfname/foto_count, Zertifikat-Roundtrip bis zur
öffentlichen Seite. Suite: 61 passed.
2026-06-07 21:00:14 +02:00

366 lines
14 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, Field
from typing import Optional
import os, logging, asyncio
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", "certificate"}
# ------------------------------------------------------------------
# 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 = Field(..., max_length=30)
class CaptionBody(BaseModel):
caption: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------
# 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 in ("breeder", "certificate"):
# 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")
# Blockierende Bildverarbeitung in Threadpool auslagern,
# damit der Event-Loop für andere Requests frei bleibt.
loop = asyncio.get_event_loop()
def _write_bytes(p: str, data: bytes) -> None:
with open(p, "wb") as f:
f.write(data)
# Thumbnail erzeugen
thumb_bytes = await loop.run_in_executor(
None, lambda: generate_preview(raw_data, ext)
)
thumb_path = None
if thumb_bytes:
thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp")
await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes))
# Originalbild konvertieren und speichern (Pillow direkt — WebP-Qualität 85)
def _save_original():
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
_write_bytes(file_path, raw_data)
await loop.run_in_executor(None, _save_original)
# 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 in ("breeder", "certificate"):
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