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
388 lines
15 KiB
Python
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]
|