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)
750 lines
28 KiB
Python
750 lines
28 KiB
Python
"""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)
|