banyaro/backend/routes/breeder.py
rene c8ae514c01 Feature: Tierschutz-Check, KI-Züchter-Features, Export, SEO-Update
Tierschutz-System (immer aktiv, nicht abschaltbar):
- welfare_check.py: regelbasierte Prüfung IK, Alter, Deckpause, Wurfanzahl, Genetik
- Grün/Gelb/Rot-Modal bei Wurf anlegen + Probeverpaarung
- Bei kritischem Befund + "Trotzdem fortfahren" → automatische Admin-Mail
- Tierschutz-Check nie durch Nutzer deaktivierbar

KI-Züchter-Features (pro User an/abschaltbar außer Tierschutz):
- routes/zucht_ki.py: 5 Endpunkte — Wurfankündigung, Genetik-Erklärung,
  Paarungsanalyse, Hund-Beschreibung, Jahresbericht
- Toggles in Einstellungen (ki_zucht_* Felder)
- KI-Buttons in litters.js + zuchthunde.js

KI-Routing: Privilegierte Rollen (Admin, Züchter, Moderator, Manager)
nutzen Claude Sonnet primär, lokales LLM als Fallback

Datenexport: routes/breeder_export.py — ZIP mit HTML-Dossier + ODS
(odfpy hinzugefügt in requirements.txt)

Admin-Profil: POST /admin/breeder/create-profile für Schnellprofil ohne
Antragsprozess; Admin-Rolle bleibt erhalten

Wurfformular: Dropdown aus Zuchtkartei für Vater/Mutter mit Auto-Fill;
litters.vater_id + mutter_id als FK auf zucht_hunde

Probeverpaarung: heart-fill Icon + Welfare-Block im Ergebnis

Landing Page: Züchter-Section + Feature-Gruppe, Meta-Tags, JSON-LD,
keywords, softwareVersion 2.1

SEO: llms.txt vollständig überarbeitet, robots.txt Züchter-Pfade,
sitemap.xml um Wurfbörse + Züchter-Profile erweitert

SW by-v474, APP_VER 451
2026-04-28 19:49:54 +02:00

388 lines
15 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
from typing import Optional
from database import db
from auth import get_current_user, require_premium
from mailer import send_email
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", "mail@motocamp.de")
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 zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"FROM breeder_profiles WHERE user_id=?",
(user["id"],)
).fetchone()
return {
"rolle": row["rolle"],
"breeder_status": row["breeder_status"],
"profile": dict(profile) if profile else None,
}
# ------------------------------------------------------------------
# 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(...),
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 validieren und speichern
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 or "")[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"],)
)
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
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)
)
conn.execute(
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
(user["id"], "antrag", filepath)
)
# Admin benachrichtigen
admin_html = f"""
<h2>Neuer Züchter-Antrag</h2>
<p><b>Von:</b> {user['name']} ({user['email']})</p>
<p><b>Zwingername:</b> {zwingername}</p>
<p><b>Rasse:</b> {rasse_text}</p>
<p><b>Verein:</b> {verein}</p>
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
<p><b>Stadt:</b> {stadt}</p>
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
"""
try:
await send_email(
ADMIN_EMAIL,
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
admin_html,
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/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
# ------------------------------------------------------------------
# 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
html = f"""
<h2>Willkommen als Züchter bei Banyaro!</h2>
<p>Hallo {user['name']},</p>
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
<p><a href="{APP_URL}">Zur App</a></p>
"""
try:
await send_email(
user["email"],
"Willkommen als Züchter bei Banyaro!",
html,
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
html = f"""
<h2>Dein Züchter-Antrag bei Banyaro</h2>
<p>Hallo {user['name']},</p>
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
<p><b>Grund:</b> {body.grund}</p>
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
"""
try:
await send_email(
user["email"],
"Dein Züchter-Antrag bei Banyaro",
html,
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
# ------------------------------------------------------------------
@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 = 'breeder'
AND u.breeder_status = 'approved'
""", (zwingername,)).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
return dict(row)
# ------------------------------------------------------------------
# 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] = None
rasse_text: Optional[str] = None
verein: Optional[str] = None
vdh_mitglied: Optional[int] = None
stadt: Optional[str] = None
website: Optional[str] = None
beschreibung: Optional[str] = None
@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]