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