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.
366 lines
14 KiB
Python
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
|