diff --git a/PROJEKT.md b/PROJEKT.md index 033b096..69ef35c 100644 --- a/PROJEKT.md +++ b/PROJEKT.md @@ -46,7 +46,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) --- -## Implementierungsstand (aktuell: 2026-04-24, SW by-v333, APP_VER 320) +## Implementierungsstand (aktuell: 2026-04-24, SW by-v356, APP_VER 343) ### Fertig implementiert ✅ @@ -65,6 +65,32 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) - Gesundheit, Admin, Karte-Legende: Tab-/Legende-Grid 2 Zeilen (gleiche CSS-Grid-Technik) - Hinweis: layout.css lädt vor components.css → ID-Selektor (#page-forum, #page-health, #page-admin, #page-map) nötig für Spezifität +#### Social Media Manager (2026-04-24) +- Neue Rolle `is_social_media` — eigene Seite `/social` +- Luna KI-Coach: Themen-Vorschläge, Fortschrittsbalken, rotierende Nachrichten +- **Rasse des Tages**: 1003 Wiki-Rassen = 2,75 Jahre täglicher Content mit Bild +- **🎾 Trainingstipp**: 104 Übungen in 7 Kategorien, 3 Stil-Varianten +- **🛁 Pflegetipp**: 43 Tipps rassenspezifisch, auch für normale User im Hundeprofil +- Diversitäts-Check (Warnung wenn Kategorie >40% dominiert) +- Post-Bestätigung mit Datum + URL, Ausstehend-Banner +- Medien-Upload (Kamera/Mediathek/Dateien), Instagram-Vorschau +- XP/Level-System (Rookie → 👑 Star) +- Admin: Social-Tracking (published/scheduled/ideas + letzte 10 Posts) + +#### Pflege-System (2026-04-24) +- `pflege_tipps` DB-Tabelle: 43 Tipps in 10 Kategorien (Fell, Krallen, Zähne, Ohren, Augen, Pfoten, Parasiten, Saisonal, Gesundheitsvorsorge, Welpen-Pflege) +- Hundeprofil: 🛁 Pflegetipps — Tipp des Tages (saisonal) + vollständige Kategorieliste +- Rassen-Autocomplete im Hundeprofil mit Wiki-Match-Badge +- `dogs.rasse_id` FK → `wiki_rassen` für präzise Filterung + +#### Breed-Enricher Wikipedia-grounded (2026-04-24) +- Korrektheit 2.3→~4.5 durch Wikipedia-Quelltext als Basis +- Claude Haiku extrahiert Fakten aus Wikipedia-Text (de/en Fallback) +- `ki_source` ('wikipedia_de/en'/'none'), `ki_model` getrackt +- LLM-as-Judge Evaluator im Admin, Gemma-Reset-Button +- 1003 Rassen, limit=2000 (ein Rutsch) +- LM Studio: Mac 10.47.11.70:11435, Modell gemma-4-31b-it + #### Infrastruktur - SSH-Port DS1621: 4711 (geändert von 22, 2026-04-24) diff --git a/backend/main.py b/backend/main.py index 2865269..4cfad18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -121,6 +121,7 @@ from routes.training import router as training_router from routes.praise import router as praise_router from routes.weather import router as weather_router from routes.social import router as social_router +from routes.moderation import router as moderation_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -161,6 +162,7 @@ app.include_router(stats_router, prefix="/api/stats", tags=[ app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"]) app.include_router(training_router, prefix="/api/training", tags=["Training"]) app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) +app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) # ------------------------------------------------------------------ diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py new file mode 100644 index 0000000..128748b --- /dev/null +++ b/backend/routes/moderation.py @@ -0,0 +1,223 @@ +"""BAN YARO — Moderations-Panel Backend""" +from fastapi import APIRouter, Depends, HTTPException +from database import db +from auth import get_current_user + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Dependency: Moderator oder Admin +# ------------------------------------------------------------------ +def require_moderator(user=Depends(get_current_user)): + if not (user.get("is_moderator") or user["rolle"] == "admin"): + raise HTTPException(403, "Nur für Moderatoren.") + return user + + +# ------------------------------------------------------------------ +# GET /api/moderation/stats — Übersicht +# ------------------------------------------------------------------ +@router.get("/stats") +async def mod_stats(user=Depends(require_moderator)): + with db() as conn: + open_reports = conn.execute( + "SELECT COUNT(*) FROM forum_reports WHERE resolved=0" + ).fetchone()[0] + + pending_fotos = 0 + try: + pending_fotos = conn.execute( + "SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'" + ).fetchone()[0] + except Exception: + pass + + banned_users = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_banned=1" + ).fetchone()[0] + + pending_zuchter = 0 + try: + pending_zuchter = conn.execute( + "SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0" + ).fetchone()[0] + except Exception: + pass + + return { + "open_reports": open_reports, + "pending_fotos": pending_fotos, + "banned_users": banned_users, + "pending_zuchter": pending_zuchter, + } + + +# ------------------------------------------------------------------ +# GET /api/moderation/reports — gemeldete Inhalte +# ------------------------------------------------------------------ +@router.get("/reports") +async def mod_reports(user=Depends(require_moderator)): + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, + u.name AS melder_name, + CASE r.target_type + WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) + WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) + END AS content_preview + FROM forum_reports r + LEFT JOIN users u ON u.id=r.user_id + WHERE r.resolved=0 + ORDER BY r.created_at DESC + LIMIT 100 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/moderation/reports/{id} — Meldung erledigen +# ------------------------------------------------------------------ +@router.patch("/reports/{rid}") +async def mod_resolve_report(rid: int, user=Depends(require_moderator)): + with db() as conn: + r = conn.execute( + "SELECT resolved FROM forum_reports WHERE id=?", (rid,) + ).fetchone() + if not r: + raise HTTPException(404, "Meldung nicht gefunden.") + new_state = 0 if r["resolved"] else 1 + conn.execute( + "UPDATE forum_reports SET resolved=? WHERE id=?", + (new_state, rid) + ) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/moderation/users — User-Liste (Basisinfos) +# ------------------------------------------------------------------ +@router.get("/users") +async def mod_users( + q: str = "", + banned: int = 0, + limit: int = 50, + offset: int = 0, + user=Depends(require_moderator), +): + with db() as conn: + where = "WHERE 1=1" + params = [] + if q.strip(): + where += " AND (name LIKE ? OR email LIKE ?)" + params.extend([f"%{q.strip()}%", f"%{q.strip()}%"]) + if banned: + where += " AND is_banned=1" + + # E-Mail nur für Admins; Moderatoren sehen maskierte Version + email_col = "email" if user["rolle"] == "admin" else \ + "SUBSTR(email,1,2)||'***@'||SUBSTR(email,INSTR(email,'@')+1) AS email" + + rows = conn.execute(f""" + SELECT id, name, {email_col}, rolle, is_moderator, is_banned, ban_reason, created_at + FROM users + {where} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]).fetchall() + + total = conn.execute( + f"SELECT COUNT(*) FROM users {where}", params + ).fetchone()[0] + + return {"users": [dict(r) for r in rows], "total": total} + + +# ------------------------------------------------------------------ +# PATCH /api/moderation/users/{id} — Ban / Unban +# ------------------------------------------------------------------ +@router.patch("/users/{uid}") +async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)): + allowed_fields = {"is_banned", "ban_reason"} + updates = {k: v for k, v in data.items() if k in allowed_fields} + if not updates: + raise HTTPException(400, "Keine erlaubten Felder.") + + with db() as conn: + target = conn.execute( + "SELECT id, rolle, name FROM users WHERE id=?", (uid,) + ).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + if target["rolle"] == "admin" and user["rolle"] != "admin": + raise HTTPException(403, "Admins können nur von Admins verwaltet werden.") + + cols = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {cols} WHERE id=?", + [*updates.values(), uid] + ) + row = conn.execute( + "SELECT id, name, rolle, is_banned, ban_reason FROM users WHERE id=?", + (uid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/moderation/fotos — Wiki-Foto-Einreichungen (pending) +# ------------------------------------------------------------------ +@router.get("/fotos") +async def mod_fotos(user=Depends(require_moderator)): + with db() as conn: + try: + rows = conn.execute(""" + SELECT s.id, s.rasse_slug, s.foto_url, s.created_at, + u.name AS user_name, + r.name AS rasse_name, r.foto_url AS aktuell_foto + FROM wiki_foto_submissions s + LEFT JOIN users u ON u.id=s.user_id + LEFT JOIN wiki_rassen r ON r.slug=s.rasse_slug + WHERE s.status='pending' + ORDER BY s.created_at ASC + LIMIT 50 + """).fetchall() + return [dict(r) for r in rows] + except Exception: + return [] + + +# ------------------------------------------------------------------ +# PATCH /api/moderation/fotos/{id} — Foto genehmigen / ablehnen +# ------------------------------------------------------------------ +@router.patch("/fotos/{foto_id}") +async def mod_foto_action(foto_id: int, data: dict, user=Depends(require_moderator)): + action = data.get("action") + if action not in ("approve", "reject"): + raise HTTPException(400, "action muss 'approve' oder 'reject' sein.") + + with db() as conn: + sub = conn.execute( + "SELECT id, rasse_slug, foto_url FROM wiki_foto_submissions WHERE id=?", + (foto_id,) + ).fetchone() + if not sub: + raise HTTPException(404, "Einreichung nicht gefunden.") + + if action == "approve": + conn.execute( + "UPDATE wiki_foto_submissions SET status='approved' WHERE id=?", + (foto_id,) + ) + conn.execute( + "UPDATE wiki_rassen SET foto_url=? WHERE slug=?", + (sub["foto_url"], sub["rasse_slug"]) + ) + else: + reason = data.get("reject_reason", "Nicht geeignet.") + conn.execute( + "UPDATE wiki_foto_submissions SET status='rejected', reject_reason=? WHERE id=?", + (reason, foto_id) + ) + + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index ba0cd02..14b694d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -185,6 +185,11 @@ Social Media +
+