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.
591 lines
26 KiB
Python
591 lines
26 KiB
Python
"""BAN YARO — Züchter-Verwaltung (Antrag, Admin-Prüfung)"""
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional
|
|
|
|
from database import db
|
|
from auth import get_current_user, require_premium
|
|
from mailer import send_email, email_html
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TZ = ZoneInfo("Europe/Berlin")
|
|
|
|
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
|
|
os.makedirs(BREEDER_DOCS_DIR, exist_ok=True)
|
|
|
|
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@banyaro.app")
|
|
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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 verifizierte Züchter.")
|
|
return user
|
|
|
|
def require_admin(user=Depends(get_current_user)):
|
|
if user["rolle"] != "admin":
|
|
raise HTTPException(403, "Nur für Admins.")
|
|
return user
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/status — eigener Antragsstatus
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/status")
|
|
async def breeder_status(user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
profile = None
|
|
if row["rolle"] in ("breeder", "admin"):
|
|
profile = conn.execute(
|
|
"SELECT id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
|
|
"FROM breeder_profiles WHERE user_id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
if profile:
|
|
logo = conn.execute(
|
|
"""SELECT file_path FROM breeder_photos
|
|
WHERE breeder_id=? AND entity_type='breeder'
|
|
ORDER BY is_primary DESC, id LIMIT 1""",
|
|
(profile["id"],)
|
|
).fetchone()
|
|
result = {
|
|
"rolle": row["rolle"],
|
|
"breeder_status": row["breeder_status"],
|
|
"profile": dict(profile) if profile else None,
|
|
}
|
|
if profile:
|
|
result["profile"]["logo_url"] = f"/media/{logo['file_path']}" if logo else None
|
|
return result
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/breeder/apply — Antrag stellen
|
|
# ------------------------------------------------------------------
|
|
@router.post("/breeder/apply")
|
|
async def breeder_apply(
|
|
zwingername: str = Form(...),
|
|
rasse_text: str = Form(...),
|
|
verein: str = Form(...),
|
|
vdh_mitglied: int = Form(0),
|
|
stadt: str = Form(...),
|
|
website: str = Form(""),
|
|
beschreibung: str = Form(""),
|
|
dokument: UploadFile = File(None),
|
|
user=Depends(get_current_user),
|
|
):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if row["rolle"] == "breeder":
|
|
raise HTTPException(400, "Du bist bereits verifizierter Züchter.")
|
|
if row["breeder_status"] == "pending":
|
|
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
|
|
|
|
# Dokument optional speichern
|
|
filepath = None
|
|
if dokument and dokument.filename:
|
|
data = await dokument.read()
|
|
if len(data) > 10 * 1024 * 1024:
|
|
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
|
|
ext = os.path.splitext(dokument.filename)[1].lower()
|
|
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
|
|
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
|
|
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
|
|
os.makedirs(user_doc_dir, exist_ok=True)
|
|
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
|
|
filepath = os.path.join(user_doc_dir, filename)
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
|
|
with db() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='pending' WHERE id=?",
|
|
(user["id"],)
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
|
|
"VALUES (?,?,?,?,?,?,?,?) "
|
|
"ON CONFLICT(user_id) DO UPDATE SET "
|
|
"zwingername=excluded.zwingername, rasse_text=excluded.rasse_text, "
|
|
"verein=excluded.verein, vdh_mitglied=excluded.vdh_mitglied, "
|
|
"stadt=excluded.stadt, website=excluded.website, beschreibung=excluded.beschreibung, "
|
|
"verified_at=NULL",
|
|
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
|
|
)
|
|
if filepath:
|
|
conn.execute(
|
|
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
|
|
(user["id"], "antrag", filepath)
|
|
)
|
|
|
|
# Admin benachrichtigen
|
|
admin_body = f"""
|
|
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
|
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
|
|
</table>"""
|
|
try:
|
|
await send_email(
|
|
ADMIN_EMAIL,
|
|
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
|
email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"),
|
|
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Admin-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": "Antrag eingereicht. Du wirst per E-Mail benachrichtigt."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeders/pending — offene Anträge
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeders/pending")
|
|
async def admin_pending_breeders(admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
rows = conn.execute("""
|
|
SELECT u.id, u.name, u.email, u.created_at, u.breeder_status,
|
|
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
|
bp.stadt, bp.website, bp.beschreibung, bp.created_at AS antrag_at,
|
|
(SELECT COUNT(*) FROM breeder_documents WHERE user_id=u.id) AS dok_count
|
|
FROM users u
|
|
JOIN breeder_profiles bp ON bp.user_id = u.id
|
|
WHERE u.breeder_status = 'pending'
|
|
ORDER BY bp.created_at ASC
|
|
""").fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeders — alle aktiven Züchter
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeders")
|
|
async def admin_all_breeders(admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
rows = conn.execute("""
|
|
SELECT u.id, u.name, u.email, u.created_at, u.subscription_tier,
|
|
u.breeder_status, u.last_login,
|
|
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
|
bp.stadt, bp.website, bp.verified_at,
|
|
(SELECT COUNT(*) FROM litters WHERE user_id=u.id) AS wuerfe_count,
|
|
(SELECT COUNT(*) FROM dogs WHERE user_id=u.id) AS hunde_count
|
|
FROM users u
|
|
LEFT JOIN breeder_profiles bp ON bp.user_id = u.id
|
|
WHERE u.rolle = 'breeder' OR u.breeder_status = 'approved'
|
|
ORDER BY CASE WHEN bp.verified_at IS NULL THEN 1 ELSE 0 END,
|
|
bp.verified_at DESC, u.created_at DESC
|
|
""").fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeder/{user_id}/documents")
|
|
async def admin_breeder_documents(user_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
docs = conn.execute(
|
|
"SELECT id, dokument_typ, file_path, uploaded_at FROM breeder_documents WHERE user_id=?",
|
|
(user_id,)
|
|
).fetchall()
|
|
return [dict(d) for d in docs]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeder/{user_id}/document/{doc_id} — Datei herunterladen
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeder/{user_id}/document/{doc_id}")
|
|
async def admin_download_document(user_id: int, doc_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT file_path FROM breeder_documents WHERE id=? AND user_id=?",
|
|
(doc_id, user_id)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Dokument nicht gefunden.")
|
|
path = row["file_path"]
|
|
if not os.path.exists(path):
|
|
raise HTTPException(404, "Datei nicht auf Datenträger.")
|
|
return FileResponse(path)
|
|
|
|
|
|
class RejectBody(BaseModel):
|
|
grund: str = Field(..., min_length=3, max_length=2000)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/{user_id}/approve — Freischalten
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/{user_id}/approve")
|
|
async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
user = conn.execute(
|
|
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if not user:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if user["breeder_status"] != "pending":
|
|
raise HTTPException(400, "Kein offener Antrag.")
|
|
|
|
conn.execute(
|
|
"UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?",
|
|
(user_id,)
|
|
)
|
|
conn.execute(
|
|
"UPDATE breeder_profiles SET verified_at=datetime('now') WHERE user_id=?",
|
|
(user_id,)
|
|
)
|
|
|
|
# Bestätigungs-Mail
|
|
approve_body = f"""
|
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
|
<p style="margin:0 0 16px">
|
|
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
|
|
Ab sofort hast du Zugang zu allen Züchter-Features.
|
|
</p>"""
|
|
try:
|
|
await send_email(
|
|
user["email"],
|
|
"Willkommen als Züchter bei Ban Yaro!",
|
|
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
|
|
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Bestätigungs-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": f"{user['name']} als Züchter freigeschaltet."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/{user_id}/reject — Ablehnen
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/{user_id}/reject")
|
|
async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
user = conn.execute(
|
|
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if not user:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if user["breeder_status"] != "pending":
|
|
raise HTTPException(400, "Kein offener Antrag.")
|
|
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='rejected' WHERE id=?",
|
|
(user_id,)
|
|
)
|
|
|
|
# Ablehnungs-Mail
|
|
import html as _h
|
|
reject_body = f"""
|
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
|
<p style="margin:0 0 16px">
|
|
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
|
|
</p>
|
|
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
|
|
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
|
|
<b>Grund:</b> {_h.escape(body.grund)}
|
|
</div>
|
|
<p style="margin:0;color:#666;font-size:14px">
|
|
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
|
|
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
|
|
</p>"""
|
|
try:
|
|
await send_email(
|
|
user["email"],
|
|
"Dein Züchter-Antrag bei Ban Yaro",
|
|
email_html(reject_body),
|
|
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Ablehnungs-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": f"Antrag von {user['name']} abgelehnt."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/profil/{zwingername} — öffentliches Profil (angereichert)
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/profil/{zwingername}")
|
|
async def breeder_public_profile(zwingername: str):
|
|
with db() as conn:
|
|
row = conn.execute("""
|
|
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
|
bp.stadt, bp.website, bp.beschreibung,
|
|
bp.location_lat, bp.location_lng, bp.verified_at, bp.created_at,
|
|
u.id AS zuechter_user_id,
|
|
u.name AS zuechter_name
|
|
FROM breeder_profiles bp
|
|
JOIN users u ON u.id = bp.user_id
|
|
WHERE LOWER(bp.zwingername) = LOWER(?)
|
|
AND u.rolle IN ('breeder', 'admin')
|
|
AND (u.breeder_status = 'approved' OR u.rolle = 'admin')
|
|
""", (zwingername,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Züchter nicht gefunden.")
|
|
|
|
breeder_id = row["id"]
|
|
result = dict(row)
|
|
|
|
# Öffentliche Zuchthunde + ihre wichtigsten Gesundheitstests + Titel
|
|
hunde_rows = conn.execute("""
|
|
SELECT id, name, rufname, geschlecht, geburtsdatum, farbe, zuchtbuchnummer, foto_url
|
|
FROM zucht_hunde
|
|
WHERE breeder_id=? AND is_public=1 AND (sterbedatum IS NULL OR sterbedatum='')
|
|
ORDER BY geschlecht, name
|
|
""", (breeder_id,)).fetchall()
|
|
|
|
hunde = []
|
|
for h in hunde_rows:
|
|
hund = dict(h)
|
|
# Gesundheitstests (nur öffentliche, nur HD/ED/Augen/Herz)
|
|
tests = conn.execute("""
|
|
SELECT test_typ, ergebnis, test_name, untersuch_am
|
|
FROM dog_health_tests
|
|
WHERE hund_id=? AND is_public=1
|
|
AND test_typ IN ('HD','ED','augen','herz','OCD','patella','ZTP')
|
|
ORDER BY test_typ, untersuch_am DESC
|
|
""", (h["id"],)).fetchall()
|
|
seen = set()
|
|
hund["health_tests"] = []
|
|
for t in tests:
|
|
if t["test_typ"] not in seen:
|
|
seen.add(t["test_typ"])
|
|
hund["health_tests"].append(dict(t))
|
|
# Gentests (nur öffentliche, Zusammenfassung)
|
|
gentests = conn.execute("""
|
|
SELECT COUNT(*) as total,
|
|
SUM(CASE WHEN ergebnis_klasse='clear' THEN 1 ELSE 0 END) as clear_cnt
|
|
FROM dog_genetic_tests WHERE hund_id=? AND is_public=1
|
|
""", (h["id"],)).fetchone()
|
|
hund["gentests_total"] = gentests["total"] or 0
|
|
hund["gentests_clear"] = gentests["clear_cnt"] or 0
|
|
# Auszeichnungen (nur Zucht/Champion)
|
|
titles = conn.execute("""
|
|
SELECT titel_name FROM dog_titles
|
|
WHERE hund_id=? AND titel_typ IN ('champion','zucht','ausstellung')
|
|
ORDER BY verliehen_am DESC LIMIT 3
|
|
""", (h["id"],)).fetchall()
|
|
hund["titel"] = [t["titel_name"] for t in titles]
|
|
hunde.append(hund)
|
|
|
|
result["hunde"] = hunde
|
|
|
|
# Sichtbare Würfe
|
|
wuerfe = conn.execute("""
|
|
SELECT id, wurf_rang, wurf_name, vater_name, mutter_name,
|
|
geburt_datum, erwartetes_datum,
|
|
status, welpen_gesamt, welpen_verfuegbar, preis_spanne, beschreibung
|
|
FROM litters
|
|
WHERE breeder_id=? AND sichtbar=1 AND status != 'abgeschlossen'
|
|
ORDER BY COALESCE(geburt_datum, erwartetes_datum) DESC
|
|
""", (breeder_id,)).fetchall()
|
|
result["wuerfe"] = [dict(w) for w in wuerfe]
|
|
|
|
# Mitgliedschaften & Zertifikate (öffentliche Logos/Badges mit Caption)
|
|
certs = conn.execute("""
|
|
SELECT id, file_path, thumbnail_path, caption FROM breeder_photos
|
|
WHERE breeder_id=? AND entity_type='certificate' AND visibility='public'
|
|
ORDER BY sort_order
|
|
""", (breeder_id,)).fetchall()
|
|
result["zertifikate"] = [{
|
|
"id": c["id"],
|
|
"url": f"/media/{c['file_path']}",
|
|
"thumbnail_url": f"/media/{c['thumbnail_path']}" if c["thumbnail_path"] else f"/media/{c['file_path']}",
|
|
"caption": c["caption"],
|
|
} for c in certs]
|
|
|
|
# Gesundheits-Statistik (aggregiert über alle öffentlichen Hunde)
|
|
hd_stats = conn.execute("""
|
|
SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests
|
|
WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1)
|
|
AND test_typ='HD' AND is_public=1
|
|
GROUP BY ergebnis
|
|
""", (breeder_id,)).fetchall()
|
|
result["hd_stats"] = [dict(r) for r in hd_stats]
|
|
|
|
ed_stats = conn.execute("""
|
|
SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests
|
|
WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1)
|
|
AND test_typ='ED' AND is_public=1
|
|
GROUP BY ergebnis
|
|
""", (breeder_id,)).fetchall()
|
|
result["ed_stats"] = [dict(r) for r in ed_stats]
|
|
|
|
# Logo = primäres Bild der entity_type='breeder' Fotos
|
|
logo = conn.execute("""
|
|
SELECT file_path FROM breeder_photos
|
|
WHERE breeder_id=? AND entity_type='breeder' AND is_primary=1
|
|
LIMIT 1
|
|
""", (breeder_id,)).fetchone()
|
|
if not logo:
|
|
logo = conn.execute("""
|
|
SELECT file_path FROM breeder_photos
|
|
WHERE breeder_id=? AND entity_type='breeder'
|
|
ORDER BY sort_order, id LIMIT 1
|
|
""", (breeder_id,)).fetchone()
|
|
result["logo_url"] = f"/media/{logo['file_path']}" if logo else None
|
|
|
|
# Öffentliche Fotos für die Gallery (alle entity_type='breeder', max. 12)
|
|
photos = conn.execute("""
|
|
SELECT file_path, thumbnail_path, caption, is_primary FROM breeder_photos
|
|
WHERE breeder_id=? AND entity_type='breeder' AND visibility IN ('public','inquiry')
|
|
ORDER BY is_primary DESC, sort_order, id LIMIT 12
|
|
""", (breeder_id,)).fetchall()
|
|
result["fotos"] = [{
|
|
"url": f"/media/{p['file_path']}",
|
|
"thumb": f"/media/{p['thumbnail_path']}" if p['thumbnail_path'] else f"/media/{p['file_path']}",
|
|
"caption": p["caption"] or "",
|
|
"primary": bool(p["is_primary"]),
|
|
} for p in photos]
|
|
|
|
return result
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/create-profile — Admin-Schnellprofil
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/create-profile")
|
|
async def admin_create_profile(admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id FROM breeder_profiles WHERE user_id=?", (admin["id"],)
|
|
).fetchone()
|
|
if existing:
|
|
return {"message": "Profil existiert bereits.", "profile_id": existing["id"]}
|
|
cur = conn.execute(
|
|
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt, verified_at) "
|
|
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
|
(admin["id"], "Admin-Zwinger", "Alle Rassen", "Admin", "Überall")
|
|
)
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='approved' WHERE id=?", (admin["id"],)
|
|
)
|
|
return {"message": "Admin-Züchterprofil angelegt.", "profile_id": cur.lastrowid}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# PUT /api/breeder/profile — eigenes Profil bearbeiten
|
|
# ------------------------------------------------------------------
|
|
class BreederProfileUpdate(BaseModel):
|
|
zwingername: Optional[str] = Field(None, max_length=200)
|
|
rasse_text: Optional[str] = Field(None, max_length=200)
|
|
verein: Optional[str] = Field(None, max_length=200)
|
|
vdh_mitglied: Optional[int] = None
|
|
stadt: Optional[str] = Field(None, max_length=200)
|
|
website: Optional[str] = Field(None, max_length=500)
|
|
beschreibung: Optional[str] = Field(None, max_length=10000)
|
|
|
|
@router.get("/breeder/my-editor")
|
|
async def breeder_my_editor(user=Depends(require_breeder)):
|
|
"""Daten für den Profil-Editor: Profil + eigene Würfe + Speicherverbrauch.
|
|
(Frontend breeder-editor.js stammt aus 459cd42 — dieser Lese-Endpoint
|
|
ging damals im Worktree-Merge verloren, wie /partner/my-profile.)"""
|
|
from routes.breeder_photos import _photo_dict
|
|
with db() as conn:
|
|
profile = conn.execute(
|
|
"SELECT * FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
|
).fetchone()
|
|
if not profile:
|
|
raise HTTPException(404, "Noch kein Züchter-Profil angelegt.")
|
|
profile = dict(profile)
|
|
profile["photos"] = [_photo_dict(r) for r in conn.execute(
|
|
"SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='breeder' ORDER BY sort_order",
|
|
(profile["id"],)
|
|
).fetchall()]
|
|
# Mitgliedschaften & Zertifikate (Logos/Badges fürs öffentliche Profil)
|
|
profile["certificates"] = [_photo_dict(r) for r in conn.execute(
|
|
"SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='certificate' ORDER BY sort_order",
|
|
(profile["id"],)
|
|
).fetchall()]
|
|
litters = [dict(r) for r in conn.execute(
|
|
"""SELECT l.*,
|
|
(SELECT COUNT(*) FROM breeder_photos p
|
|
WHERE p.entity_type='litter' AND p.entity_id=l.id) AS foto_count
|
|
FROM litters l WHERE l.breeder_id=? ORDER BY l.created_at DESC""",
|
|
(profile["id"],)
|
|
).fetchall()]
|
|
|
|
# Speicherverbrauch der Züchter-Medien (MEDIA_DIR/breeders/{breeder_id}/**)
|
|
media_dir = os.getenv("MEDIA_DIR", "/data/media")
|
|
base = os.path.join(media_dir, "breeders", str(profile["id"]))
|
|
total = 0
|
|
if os.path.isdir(base):
|
|
for root, _dirs, files in os.walk(base):
|
|
for f in files:
|
|
try:
|
|
total += os.path.getsize(os.path.join(root, f))
|
|
except OSError:
|
|
pass
|
|
|
|
return {
|
|
"profile": profile,
|
|
"litters": litters,
|
|
"storage_mb": round(total / (1024 * 1024), 4),
|
|
"storage_limit_mb": 200,
|
|
}
|
|
|
|
|
|
@router.put("/breeder/profile")
|
|
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):
|
|
with db() as conn:
|
|
profile = conn.execute(
|
|
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
|
).fetchone()
|
|
if not profile:
|
|
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
|
|
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
if not fields:
|
|
return {"message": "Keine Änderungen."}
|
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
|
conn.execute(
|
|
f"UPDATE breeder_profiles SET {set_clause} WHERE id=?",
|
|
(*fields.values(), profile["id"])
|
|
)
|
|
return {"message": "Profil aktualisiert."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/map — alle Züchter für Karte
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/map")
|
|
async def breeder_map_markers():
|
|
with db() as conn:
|
|
rows = conn.execute("""
|
|
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.stadt,
|
|
bp.location_lat, bp.location_lng
|
|
FROM breeder_profiles bp
|
|
JOIN users u ON u.id = bp.user_id
|
|
WHERE bp.verified_at IS NOT NULL
|
|
AND u.rolle = 'breeder'
|
|
""").fetchall()
|
|
return [dict(r) for r in rows]
|