banyaro/backend/routes/wiki.py
rene c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00

750 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Hunde-Wiki Routes"""
import os
import shutil
import time
import logging
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from database import db
from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip
from cache import ttl_cache
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds")
SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions")
GALLERY_DIR = os.path.join(BREEDS_DIR, "gallery")
router = APIRouter()
# ------------------------------------------------------------------
# Honeypot — URL die kein echter Browser aufruft
# GET /api/wiki/trap → IP 24h sperren
# ------------------------------------------------------------------
@router.get("/trap", include_in_schema=False)
async def honeypot(request: Request):
block_ip(request, hours=24)
logger.warning("Honeypot ausgelöst von %s", request.client.host if request.client else "?")
raise HTTPException(404, "Not found")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class BerichtCreate(BaseModel):
rasse: str
titel: str
text: str
# ------------------------------------------------------------------
# Hilfsfunktion Quiz-Scoring
# ------------------------------------------------------------------
def _quiz_score(rasse: dict, params: dict) -> int:
score = 0
if params.get("groesse") and rasse["groesse"] == params["groesse"]:
score += 2
# Aktivität: exakt = 2, eine Stufe daneben = 1
aktiv_map = {"niedrig": 0, "mittel": 1, "hoch": 2, "sehr_hoch": 3}
if params.get("aktivitaet"):
a_user = aktiv_map.get(params["aktivitaet"], -1)
a_rasse = aktiv_map.get(rasse["aktivitaet"], -1)
diff = abs(a_user - a_rasse)
if diff == 0:
score += 2
elif diff == 1:
score += 1
# Erfahrung: anfaenger bekommt Bonus für einfache Rassen
erf_map = {"anfaenger": 0, "fortgeschritten": 1, "experte": 2}
if params.get("erfahrung"):
e_user = erf_map.get(params["erfahrung"], -1)
e_rasse = erf_map.get(rasse["erfahrung"], -1)
if e_user >= e_rasse:
score += 2
elif e_user == e_rasse - 1:
score += 1
# Kinder
if params.get("kinder") in ("true", "True", "1"):
if rasse["kinder_geeignet"]:
score += 1
# Wohnung
if params.get("wohnung") in ("true", "True", "1"):
if rasse["wohnung_geeignet"]:
score += 2
elif params.get("wohnung") in ("false", "False", "0"):
if not rasse["wohnung_geeignet"]:
score += 1
return score
# ------------------------------------------------------------------
# GET /api/wiki/stats — Seed-Status (1h TTL-Cache, statische Anzahl)
# ------------------------------------------------------------------
@ttl_cache(ttl=3600)
def _wiki_stats() -> dict:
with db() as conn:
row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone()
total = row["total"] if row else 0
return {"total_breeds": total, "seeded": total > 0}
@router.get("/stats")
async def get_stats():
return _wiki_stats()
# ------------------------------------------------------------------
# Gruppen-Liste für Filter-Dropdown statisch, 1h TTL-Cache
# ------------------------------------------------------------------
@ttl_cache(ttl=3600)
def _wiki_gruppen() -> list[str]:
with db() as conn:
rows = conn.execute(
"SELECT DISTINCT gruppe FROM wiki_rassen "
"WHERE gruppe IS NOT NULL ORDER BY gruppe"
).fetchall()
return [r["gruppe"] for r in rows]
# ------------------------------------------------------------------
# GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert)
# ------------------------------------------------------------------
@router.get("/rassen")
async def get_rassen(
request: Request,
search: str = Query(""),
gruppe: str = Query(""),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
rl_check(request, max_requests=60, window_seconds=60, key="wiki_list")
conditions = []
args = []
if search:
conditions.append("(LOWER(name) LIKE ? OR LOWER(gruppe) LIKE ? OR LOWER(temperament) LIKE ?)")
like = f"%{search.lower()}%"
args += [like, like, like]
if gruppe:
conditions.append("gruppe = ?")
args.append(gruppe)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
args_paged = args + [limit, offset]
with db() as conn:
rows = conn.execute(f"""
SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet,
(SELECT s.foto_url FROM wiki_foto_submissions s
WHERE s.rasse_id = wiki_rassen.id AND s.status='approved'
ORDER BY s.reviewed_at DESC LIMIT 1) AS user_foto
FROM wiki_rassen
{where}
ORDER BY name ASC
LIMIT ? OFFSET ?
""", args_paged).fetchall()
count_row = conn.execute(f"""
SELECT COUNT(*) as total FROM wiki_rassen {where}
""", args).fetchone()
# Alle Gruppen für Filter-Dropdown (gecached, 1h TTL)
gruppen = _wiki_gruppen()
return {
"breeds": [dict(r) for r in rows],
"total": count_row["total"] if count_row else 0,
"gruppen": gruppen,
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
# ------------------------------------------------------------------
@router.get("/rassen/{rasse_slug}")
async def get_rasse(rasse_slug: str, request: Request):
rl_check(request, max_requests=30, window_seconds=60, key="wiki_detail")
with db() as conn:
rasse = conn.execute(
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
rows = conn.execute(
"""SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor
FROM wiki_berichte wb
JOIN users u ON u.id = wb.user_id
WHERE wb.rasse = ?
ORDER BY wb.created_at DESC
LIMIT 50""",
(rasse_slug,),
).fetchall()
user_fotos = conn.execute("""
SELECT s.foto_url, u.name AS user_name, s.created_at
FROM wiki_foto_submissions s
JOIN users u ON u.id = s.user_id
WHERE s.rasse_id = ? AND s.status = 'approved'
ORDER BY s.reviewed_at DESC
LIMIT 10
""", (rasse["id"],)).fetchall()
result = dict(rasse)
result["berichte"] = [dict(r) for r in rows]
result["user_fotos"] = [dict(r) for r in user_fotos]
return result
# ------------------------------------------------------------------
# POST /api/wiki/berichte — Community-Bericht hinzufügen
# ------------------------------------------------------------------
@router.post("/berichte")
async def create_bericht(data: BerichtCreate, user=Depends(get_current_user)):
# Prüfen ob die Rasse in der DB existiert
with db() as conn:
rasse_row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug = ?", (data.rasse,)
).fetchone()
if not rasse_row:
raise HTTPException(400, "Ungültige Rasse.")
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:
cur = conn.execute(
"""INSERT INTO wiki_berichte (user_id, rasse, titel, text)
VALUES (?, ?, ?, ?)""",
(user["id"], data.rasse, data.titel.strip(), data.text.strip()),
)
row = conn.execute(
"SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor "
"FROM wiki_berichte wb JOIN users u ON u.id = wb.user_id "
"WHERE wb.id = ?",
(cur.lastrowid,),
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/wiki/berichte/{id} — Bericht löschen (nur eigene)
# ------------------------------------------------------------------
@router.delete("/berichte/{bericht_id}")
async def delete_bericht(bericht_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id, user_id FROM wiki_berichte WHERE id = ?",
(bericht_id,),
).fetchone()
if not row:
raise HTTPException(404, "Bericht nicht gefunden.")
if row["user_id"] != user["id"]:
raise HTTPException(403, "Nicht erlaubt.")
conn.execute("DELETE FROM wiki_berichte WHERE id = ?", (bericht_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/wiki/quiz/result — Quiz-Ergebnis berechnen
# ------------------------------------------------------------------
@router.get("/quiz/result")
async def quiz_result(
groesse: str = Query(""),
aktivitaet: str = Query(""),
erfahrung: str = Query(""),
kinder: str = Query(""),
wohnung: str = Query(""),
):
params = {
"groesse": groesse,
"aktivitaet": aktivitaet,
"erfahrung": erfahrung,
"kinder": kinder,
"wohnung": wohnung,
}
with db() as conn:
rows = conn.execute(
"""SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet,
temperament, bred_for
FROM wiki_rassen
ORDER BY name ASC"""
).fetchall()
rassen = [dict(r) for r in rows]
if not rassen:
return {"results": []}
scored = sorted(
rassen,
key=lambda r: _quiz_score(r, params),
reverse=True,
)
top3 = [
{
"slug": r["slug"],
"name": r["name"],
"gruppe": r["gruppe"],
"groesse": r["groesse"],
"aktivitaet": r["aktivitaet"],
"erfahrung": r["erfahrung"],
"foto_url": r["foto_url"],
"kinder_geeignet": r["kinder_geeignet"],
"wohnung_geeignet":r["wohnung_geeignet"],
"temperament": r["temperament"],
"score": _quiz_score(r, params),
}
for r in scored[:3]
]
return {"results": top3}
# ------------------------------------------------------------------
# POST /api/wiki/rassen/{slug}/foto — User reicht Foto ein
# ------------------------------------------------------------------
@router.post("/rassen/{slug}/foto", status_code=201)
async def submit_foto(
slug: str,
file: UploadFile = File(...),
rights_confirmed: int = Form(0),
user = Depends(get_current_user),
):
with db() as conn:
rasse = conn.execute(
"SELECT id, name, external_id FROM wiki_rassen WHERE slug=?", (slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
if not rights_confirmed:
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
_IMAGE_MAGIC = [
b"\xff\xd8\xff", # JPEG
b"\x89PNG\r\n\x1a\n", # PNG
b"RIFF", # WebP (RIFF....WEBP)
b"GIF87a", b"GIF89a", # GIF
]
os.makedirs(SUBMIT_DIR, exist_ok=True)
ts = int(time.time())
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
if not any(content.startswith(magic) for magic in _IMAGE_MAGIC):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).")
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
with open(path, "wb") as f:
f.write(content)
local_url = f"/media/breeds/submissions/{filename}"
with db() as conn:
# Bestehende pending-Einreichung des Users für diese Rasse ersetzen
old = conn.execute(
"SELECT foto_url FROM wiki_foto_submissions WHERE rasse_id=? AND user_id=? AND status='pending'",
(rasse["id"], user["id"])
).fetchone()
if old:
try:
old_path = old["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
if os.path.exists(old_path):
os.remove(old_path)
except Exception:
pass
conn.execute(
"DELETE FROM wiki_foto_submissions WHERE rasse_id=? AND user_id=? AND status='pending'",
(rasse["id"], user["id"])
)
conn.execute("""
INSERT INTO wiki_foto_submissions (user_id, rasse_id, foto_url, rights_confirmed)
VALUES (?,?,?,?)
""", (user["id"], rasse["id"], local_url, 1))
logger.info(f"Foto-Einreichung: {rasse['name']} von User {user['id']}")
return {"ok": True, "foto_url": local_url}
# ------------------------------------------------------------------
# GET /api/wiki/foto-submissions — offene Einreichungen (Mod/Admin)
# ------------------------------------------------------------------
@router.get("/foto-submissions")
async def list_submissions(user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") == "admin"):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute("""
SELECT s.id, s.foto_url, s.status, s.created_at,
u.name AS user_name,
r.name AS rasse_name, r.slug AS rasse_slug,
r.foto_url AS aktuell_foto
FROM wiki_foto_submissions s
JOIN users u ON u.id = s.user_id
JOIN wiki_rassen r ON r.id = s.rasse_id
WHERE s.status = 'pending'
ORDER BY s.created_at ASC
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen
# ------------------------------------------------------------------
class ReviewModel(BaseModel):
action: str # "approve" | "reject"
reject_reason: str = ""
@router.patch("/foto-submissions/{sub_id}")
async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") == "admin"):
raise HTTPException(403, "Nur Moderatoren.")
if data.action not in ("approve", "reject"):
raise HTTPException(400, "action muss 'approve' oder 'reject' sein.")
with db() as conn:
sub = conn.execute(
"SELECT * FROM wiki_foto_submissions WHERE id=? AND status='pending'",
(sub_id,)
).fetchone()
if not sub:
raise HTTPException(404, "Einreichung nicht gefunden.")
rasse = conn.execute(
"SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()
if data.action == "approve":
# Ins gallery-Verzeichnis verschieben
os.makedirs(GALLERY_DIR, exist_ok=True)
src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
dest_name = f"{rasse['slug']}_{sub_id}.jpg"
dest = os.path.join(GALLERY_DIR, dest_name)
try:
shutil.copy2(src, dest)
except Exception as e:
raise HTTPException(500, f"Datei konnte nicht kopiert werden: {e}")
new_url = f"/media/breeds/gallery/{dest_name}"
# Nur als Hauptbild setzen wenn noch keins vorhanden
if not rasse["foto_url"]:
conn.execute(
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(new_url, rasse["id"])
)
conn.execute("""
UPDATE wiki_foto_submissions
SET status='approved', foto_url=?, reviewed_by=?, reviewed_at=datetime('now')
WHERE id=?
""", (new_url, user["id"], sub_id))
# Push-Notification an Einreicher
try:
from routes.push import send_push_to_user
send_push_to_user(sub["user_id"], {
"title": "Foto freigeschalten!",
"body": "Dein Foto wurde im Wiki veröffentlicht.",
"type": "wiki_foto_approved",
"data": {"page": "wiki"},
})
except Exception:
pass
# Badge-Check
try:
from routes.achievements import check_and_award
with db() as conn2:
new_badges = check_and_award(sub["user_id"], conn2)
if new_badges:
try:
send_push_to_user(sub["user_id"], {
"title": "\U0001f3c5 Neues Badge!",
"body": f"Du hast '{new_badges[0]['name']}' verdient!",
"type": "badge_earned",
"data": {"page": "achievements"},
})
except Exception:
pass
except Exception:
pass
else: # reject
conn.execute("""
UPDATE wiki_foto_submissions
SET status='rejected', reviewed_by=?, reviewed_at=datetime('now'),
reject_reason=?
WHERE id=?
""", (user["id"], data.reject_reason or "Nicht geeignet.", sub_id))
# Temp-Datei löschen
try:
path = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
if os.path.exists(path):
os.remove(path)
except Exception:
pass
return {"ok": True}
# ------------------------------------------------------------------
# Hilfsfunktion: Stats für eine Rasse zusammenstellen
# ------------------------------------------------------------------
def _build_stats(conn, rasse_slug: str, user_id=None) -> dict:
dogs_count = conn.execute(
"SELECT COUNT(DISTINCT user_id) FROM dogs WHERE LOWER(rasse) = LOWER(?)",
(rasse_slug,),
).fetchone()[0]
hat_count = conn.execute(
"SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='hat'",
(rasse_slug,),
).fetchone()[0]
will_count = conn.execute(
"SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='will'",
(rasse_slug,),
).fetchone()[0]
zuchter_count = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(rasse_slug,),
).fetchone()[0]
berichte_count = conn.execute(
"SELECT COUNT(*) FROM wiki_berichte WHERE rasse=?",
(rasse_slug,),
).fetchone()[0]
user_interest = None
if user_id:
row = conn.execute(
"SELECT typ FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?",
(user_id, rasse_slug),
).fetchone()
if row:
user_interest = row["typ"]
return {
"dogs_count": dogs_count,
"hat_count": hat_count,
"will_count": will_count,
"zuchter_count": zuchter_count,
"berichte_count":berichte_count,
"user_interest": user_interest,
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug}/stats
# ------------------------------------------------------------------
@router.get("/rassen/{slug}/stats")
async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)):
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
return _build_stats(conn, slug, user["id"] if user else None)
# ------------------------------------------------------------------
# Schemas für Interesse und Züchter
# ------------------------------------------------------------------
class InteresseCreate(BaseModel):
typ: str # "hat" oder "will"
class ZuchterCreate(BaseModel):
rasse_slug: str
name: str
zwingername: str = ""
ort: str = ""
plz: str = ""
bundesland: str = ""
vdh_mitglied: int = 0
website: str = ""
telefon: str = ""
beschreibung: str = ""
# ------------------------------------------------------------------
# POST /api/wiki/rassen/{slug}/interesse
# ------------------------------------------------------------------
@router.post("/rassen/{slug}/interesse")
async def set_interesse(slug: str, data: InteresseCreate, user=Depends(get_current_user)):
if data.typ not in ("hat", "will"):
raise HTTPException(400, "typ muss 'hat' oder 'will' sein.")
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
conn.execute(
"""INSERT INTO wiki_breed_interest (user_id, rasse_slug, typ)
VALUES (?, ?, ?)
ON CONFLICT(user_id, rasse_slug) DO UPDATE SET typ=excluded.typ""",
(user["id"], slug, data.typ),
)
return _build_stats(conn, slug, user["id"])
# ------------------------------------------------------------------
# DELETE /api/wiki/rassen/{slug}/interesse
# ------------------------------------------------------------------
@router.delete("/rassen/{slug}/interesse")
async def delete_interesse(slug: str, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"DELETE FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?",
(user["id"], slug),
)
return _build_stats(conn, slug, user["id"])
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug}/zuchter
# ------------------------------------------------------------------
@router.get("/rassen/{slug}/zuchter")
async def get_zuchter_fuer_rasse(
slug: str,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
with db() as conn:
rows = conn.execute(
"""SELECT z.id, z.rasse_slug, z.name, z.zwingername, z.ort, z.plz,
z.bundesland, z.vdh_mitglied, z.website, z.telefon,
z.beschreibung, z.created_at
FROM wiki_zuchter z
WHERE z.rasse_slug=? AND z.verified=1
ORDER BY z.bundesland ASC, z.ort ASC
LIMIT ? OFFSET ?""",
(slug, limit, offset),
).fetchall()
total = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(slug,),
).fetchone()[0]
return {"zuchter": [dict(r) for r in rows], "total": total}
# ------------------------------------------------------------------
# POST /api/wiki/zuchter — Züchter einreichen
# ------------------------------------------------------------------
@router.post("/zuchter", status_code=201)
async def create_zuchter(data: ZuchterCreate, user=Depends(get_current_user)):
if not data.name.strip():
raise HTTPException(400, "Name darf nicht leer sein.")
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (data.rasse_slug,)
).fetchone()
if not rasse:
raise HTTPException(400, "Ungültige Rasse.")
cur = conn.execute(
"""INSERT INTO wiki_zuchter
(rasse_slug, name, zwingername, ort, plz, bundesland,
vdh_mitglied, website, telefon, beschreibung, verified, user_id)
VALUES (?,?,?,?,?,?,?,?,?,?,0,?)""",
(
data.rasse_slug, data.name.strip(),
data.zwingername.strip() or None,
data.ort.strip() or None,
data.plz.strip() or None,
data.bundesland.strip() or None,
data.vdh_mitglied,
data.website.strip() or None,
data.telefon.strip() or None,
data.beschreibung.strip() or None,
user["id"],
),
)
row = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/wiki/zuchter/{id} — eigene Einreichung löschen
# ------------------------------------------------------------------
@router.delete("/zuchter/{zuchter_id}")
async def delete_zuchter(zuchter_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id, user_id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
is_admin = user.get("rolle") == "admin" or user.get("is_moderator")
if row["user_id"] != user["id"] and not is_admin:
raise HTTPException(403, "Nicht erlaubt.")
conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/wiki/zuchter/pending — unverifizierte Einreichungen (Mod/Admin)
# ------------------------------------------------------------------
@router.get("/zuchter/pending")
async def list_zuchter_pending(user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT z.*, u.name AS user_name, m.name AS verified_by_name
FROM wiki_zuchter z
LEFT JOIN users u ON u.id = z.user_id
LEFT JOIN users m ON m.id = z.verified_by
ORDER BY z.verified ASC, z.created_at ASC
LIMIT 200""",
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/wiki/zuchter/{id}/verify — Züchter freigeben (Mod/Admin)
# ------------------------------------------------------------------
@router.patch("/zuchter/{zuchter_id}/verify")
async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
row = conn.execute(
"SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
from datetime import datetime
conn.execute(
"UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?",
(user["id"], datetime.utcnow().isoformat(), zuchter_id)
)
result = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
return dict(result)