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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue