banyaro/backend/routes/wiki.py
rene 32d630d5a1 Sprint 11b: Wiki-Foto-Einreichungen + Wikipedia-Foto-Scraper
- User können Fotos für Rassen vorschlagen (Upload-Modal in Rassen-Detail)
- Mod/Admin-Review-Tab im Wiki mit Freischalten/Ablehnen + Push-Notification
- wikipedia_photos.py: holt Fotos über Wikidata-QID → Wikipedia-API
- Foto-Status: 578 lokal, 186 extern, 238 ohne Foto
- DB: wiki_foto_submissions Tabelle
- SW by-v90
2026-04-15 22:01:58 +02:00

439 lines
15 KiB
Python

"""BAN YARO — Hunde-Wiki Routes"""
import os
import shutil
import time
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from database import db
from auth import get_current_user
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")
router = APIRouter()
# ------------------------------------------------------------------
# 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
# ------------------------------------------------------------------
@router.get("/stats")
async def get_stats():
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}
# ------------------------------------------------------------------
# GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert)
# ------------------------------------------------------------------
@router.get("/rassen")
async def get_rassen(
search: str = Query(""),
gruppe: str = Query(""),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
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
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
gruppen_rows = conn.execute(
"SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe"
).fetchall()
return {
"breeds": [dict(r) for r in rows],
"total": count_row["total"] if count_row else 0,
"gruppen": [r["gruppe"] for r in gruppen_rows],
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
# ------------------------------------------------------------------
@router.get("/rassen/{rasse_slug}")
async def get_rasse(rasse_slug: str):
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()
result = dict(rasse)
result["berichte"] = [dict(r) for r in rows]
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(...),
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.")
# Dateiformat prüfen
ct = file.content_type or ""
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt.")
os.makedirs(SUBMIT_DIR, exist_ok=True)
ts = int(time.time())
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
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)
VALUES (?,?,?)
""", (user["id"], rasse["id"], local_url))
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 FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()
if data.action == "approve":
# Ziel-Dateiname aus external_id ableiten
ext_id = rasse["external_id"] if rasse else None
if ext_id and str(ext_id).startswith("wd_"):
qid = str(ext_id).replace("wd_", "")
dest_name = f"{qid}.jpg"
elif ext_id:
dest_name = f"{ext_id}.jpg"
else:
dest_name = f"{rasse['slug']}.jpg"
os.makedirs(BREEDS_DIR, exist_ok=True)
src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
dest = os.path.join(BREEDS_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/{dest_name}"
conn.execute(
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(new_url, rasse["id"])
)
conn.execute("""
UPDATE wiki_foto_submissions
SET status='approved', reviewed_by=?, reviewed_at=datetime('now')
WHERE id=?
""", (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": f"Dein Foto wurde im Wiki veröffentlicht.",
"type": "wiki_foto_approved",
"data": {"page": "wiki"},
})
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}