banyaro/backend/routes/admin.py
rene e56183b642 Feature: Ratings, Lightbox, Forum-Standort, Notifications, Routen-Recording, Chat-Picker
- Bewertungssystem (ratings.py): Sterne für Sitter/Walks/Places/Routen
- Admin: Server-Log-Viewer + OSM-Cache-Statistiken
- Chat: "Neue Nachricht"-Button mit Freundesliste-Picker
- Forum: 5 neue Kategorien, Standorteingabe (locationPicker), Absende-Toast, Lightbox
- Freunde: Aktivitäts-Filter (Chips), Freundschaftsanfrage → In-App-Notification
- Sitter: locationPicker statt manuelle Koordinateneingabe + ratingStars
- Tagebuch: Bilder-Lightbox im Detail-View, iOS-Modal-Header-Fix (90svh)
- Routen: Start/Stopp-Button wechselt Zustand, nutzt Page_map.isRecording()
- Benachrichtigungen: Delete-Button sichtbar, typ-basierte Navigation, Toast-Feedback
- OSM: globales Semaphore + 429-Retry-Logic; Scheduler: München-Umland, täglich
- SW by-v225, APP_VER 202
2026-04-19 09:40:35 +02:00

479 lines
18 KiB
Python

"""BAN YARO — Admin / Moderator Backend"""
import os
import sys
import time
import platform
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db, DB_PATH
from auth import get_current_user
router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin")
_start_time = time.time()
# Audit-Tabelle anlegen (einmalig beim Import)
with db() as _conn:
_conn.executescript("""
CREATE TABLE IF NOT EXISTS admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL,
admin_name TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _audit(conn, admin, action: str, target: str = None, detail: str = None):
"""Schreibt einen Audit-Eintrag in die admin_audit-Tabelle."""
conn.execute(
"INSERT INTO admin_audit (admin_id, admin_name, action, target, detail) VALUES (?,?,?,?,?)",
(admin["id"], admin.get("name"), action, target, detail),
)
# ------------------------------------------------------------------
# Dependency: Moderator oder Admin
# ------------------------------------------------------------------
def require_mod(user=Depends(get_current_user)):
if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"):
raise HTTPException(403, "Kein Zugriff.")
return user
def require_admin(user=Depends(get_current_user)):
if user["rolle"] != "admin":
raise HTTPException(403, "Nur Admins.")
return user
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None
is_banned: Optional[int] = None
ban_reason: Optional[str] = None
class ThreadAdminPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
is_deleted: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/admin/stats
# ------------------------------------------------------------------
@router.get("/stats")
async def stats(user=Depends(require_mod)):
with db() as conn:
users_total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0]
threads = conn.execute(
"SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0"
).fetchone()[0]
posts = conn.execute(
"SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0"
).fetchone()[0]
open_reports = conn.execute(
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
).fetchone()[0]
banned = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_banned=1"
).fetchone()[0]
dogs_total = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
poison_total = conn.execute(
"SELECT COUNT(*) FROM poison WHERE geloest=0 AND expires_at > datetime('now')"
).fetchone()[0]
# Erweiterte Metriken
push_subscriptions = conn.execute(
"SELECT COUNT(*) FROM push_subscriptions"
).fetchone()[0]
active_users_7d = conn.execute(
"SELECT COUNT(*) FROM users WHERE last_seen > datetime('now', '-7 days')"
).fetchone()[0]
# Media aus diary (media_url) + health (datei_url)
media_diary = conn.execute(
"SELECT COUNT(*) FROM diary WHERE media_url IS NOT NULL AND media_url != ''"
).fetchone()[0]
media_health = conn.execute(
"SELECT COUNT(*) FROM health WHERE datei_url IS NOT NULL AND datei_url != ''"
).fetchone()[0]
media_count = media_diary + media_health
routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0]
events_total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
osm_total = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
osm_tiles = conn.execute("SELECT COUNT(*) FROM osm_tiles").fetchone()[0]
osm_by_type = {
row[0]: row[1]
for row in conn.execute(
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
).fetchall()
}
return {
"users_total": users_total,
"users_today": users_today,
"threads": threads,
"posts": posts,
"open_reports": open_reports,
"banned": banned,
"dogs_total": dogs_total,
"poison_active": poison_total,
"push_subscriptions": push_subscriptions,
"active_users_7d": active_users_7d,
"media_count": media_count,
"routes_total": routes_total,
"events_total": events_total,
"osm_total": osm_total,
"osm_tiles": osm_tiles,
"osm_by_type": osm_by_type,
}
# ------------------------------------------------------------------
# GET /api/admin/users
# ------------------------------------------------------------------
@router.get("/users")
async def list_users(
q: str = "",
rolle: str = "",
limit: int = 50,
offset: int = 0,
user=Depends(require_mod),
):
with db() as conn:
where = "WHERE 1=1"
params = []
if q.strip():
where += " AND (u.name LIKE ? OR u.email LIKE ?)"
params.extend([f"%{q.strip()}%", f"%{q.strip()}%"])
if rolle:
where += " AND u.rolle = ?"
params.append(rolle)
rows = conn.execute(f"""
SELECT u.id, u.name, u.email, u.rolle, u.is_premium,
u.is_moderator, u.is_banned, u.ban_reason,
u.created_at, u.last_login,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count
FROM users u
{where}
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
""", [*params, limit, offset]).fetchall()
total = conn.execute(f"""
SELECT COUNT(*) FROM users u {where}
""", params).fetchone()[0]
return {"users": [dict(r) for r in rows], "total": total}
# ------------------------------------------------------------------
# PATCH /api/admin/users/{id} — Rolle, Sperre
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
# Rollenwechsel nur für Admins
if data.rolle is not None and user["rolle"] != "admin":
raise HTTPException(403, "Rollenwechsel nur für Admins.")
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
raise HTTPException(400, "Ungültige Rolle.")
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.")
# Mods dürfen keine Admins sperren
if target["rolle"] == "admin" and user["rolle"] != "admin":
raise HTTPException(403, "Admins können nur von Admins verwaltet werden.")
updates = data.model_dump(exclude_none=True)
if not updates:
raise HTTPException(400, "Keine Änderungen.")
# is_moderator aus rolle ableiten wenn rolle gesetzt wird
if "rolle" in updates:
updates["is_moderator"] = 1 if updates["rolle"] in ("moderator", "admin") else 0
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, email, rolle, is_moderator, is_banned, ban_reason FROM users WHERE id=?",
(uid,)
).fetchone()
# Audit
detail_parts = []
if "is_banned" in updates:
detail_parts.append("gesperrt" if updates["is_banned"] else "entsperrt")
if "rolle" in updates:
detail_parts.append(f"Rolle→{updates['rolle']}")
_audit(conn, user, "user_patch", f"user:{uid} ({target['name']})", ", ".join(detail_parts) or None)
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/admin/users/{id} — Account löschen (Admin only)
# ------------------------------------------------------------------
@router.delete("/users/{uid}", status_code=204)
async def delete_user(uid: int, user=Depends(require_admin)):
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["id"] == user["id"]:
raise HTTPException(400, "Du kannst deinen eigenen Account nicht löschen.")
conn.execute("DELETE FROM users WHERE id=?", (uid,))
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
# ------------------------------------------------------------------
# GET /api/admin/forum/threads — alle Threads inkl. gelöschte
# ------------------------------------------------------------------
@router.get("/forum/threads")
async def admin_threads(
q: str = "",
deleted: int = 0,
limit: int = 50,
offset: int = 0,
user=Depends(require_mod),
):
with db() as conn:
where = "WHERE 1=1"
params = []
if not deleted:
where += " AND t.is_deleted=0"
if q.strip():
where += " AND (t.titel LIKE ? OR t.text LIKE ?)"
params.extend([f"%{q.strip()}%", f"%{q.strip()}%"])
rows = conn.execute(f"""
SELECT t.id, t.kategorie, t.titel, SUBSTR(t.text,1,100) AS text_preview,
t.antworten, t.likes, t.views,
t.is_pinned, t.is_locked, t.is_deleted, t.created_at,
u.id AS user_id, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id=t.user_id
{where}
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?
""", [*params, limit, offset]).fetchall()
total = conn.execute(f"""
SELECT COUNT(*) FROM forum_threads t {where}
""", params).fetchone()[0]
return {"threads": [dict(r) for r in rows], "total": total}
# ------------------------------------------------------------------
# PATCH /api/admin/forum/threads/{id}
# ------------------------------------------------------------------
@router.patch("/forum/threads/{tid}")
async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(require_mod)):
with db() as conn:
thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
updates = data.model_dump(exclude_none=True)
if not updates:
raise HTTPException(400, "Keine Änderungen.")
cols = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE forum_threads SET {cols} WHERE id=?", [*updates.values(), tid])
_audit(conn, user, "thread_patch", f"thread:{tid}", str(updates))
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/admin/forum/threads/{id}
# ------------------------------------------------------------------
@router.delete("/forum/threads/{tid}", status_code=204)
async def admin_delete_thread(tid: int, user=Depends(require_mod)):
with db() as conn:
thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
conn.execute("UPDATE forum_threads SET is_deleted=1 WHERE id=?", (tid,))
_audit(conn, user, "thread_delete", f"thread:{tid} ({thread['titel'][:60]})")
# ------------------------------------------------------------------
# DELETE /api/admin/forum/posts/{id}
# ------------------------------------------------------------------
@router.delete("/forum/posts/{pid}", status_code=204)
async def admin_delete_post(pid: int, user=Depends(require_mod)):
with db() as conn:
post = conn.execute("SELECT * FROM forum_posts WHERE id=?", (pid,)).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
conn.execute("UPDATE forum_posts SET is_deleted=1 WHERE id=?", (pid,))
conn.execute(
"UPDATE forum_threads SET antworten=MAX(0,antworten-1) WHERE id=?",
(post["thread_id"],)
)
_audit(conn, user, "post_delete", f"post:{pid} thread:{post['thread_id']}")
# ------------------------------------------------------------------
# GET /api/admin/reports — offene Meldungen
# ------------------------------------------------------------------
@router.get("/reports")
async def admin_reports(user=Depends(require_mod)):
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
ORDER BY r.resolved ASC, r.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/admin/reports/{id} — erledigen / wiedereröffnen
# ------------------------------------------------------------------
@router.patch("/reports/{rid}")
async def admin_resolve_report(rid: int, user=Depends(require_mod)):
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)
)
_audit(conn, user, "report_resolve" if new_state else "report_reopen", f"report:{rid}")
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/admin/scheduler/jobs
# ------------------------------------------------------------------
@router.get("/scheduler/jobs")
async def scheduler_jobs(user=Depends(require_admin)):
from scheduler import _scheduler
jobs = []
for job in _scheduler.get_jobs():
next_run = job.next_run_time
jobs.append({
"id": job.id,
"name": job.name,
"next_run_time": next_run.isoformat() if next_run else None,
"trigger": str(job.trigger),
})
return jobs
# ------------------------------------------------------------------
# POST /api/admin/scheduler/trigger/{job_id}
# ------------------------------------------------------------------
@router.post("/scheduler/trigger/{job_id}")
async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
from scheduler import _scheduler, _TZ as SCHED_TZ
job = _scheduler.get_job(job_id)
if not job:
raise HTTPException(404, f"Job '{job_id}' nicht gefunden.")
job.modify(next_run_time=datetime.now(tz=SCHED_TZ))
with db() as conn:
_audit(conn, user, "scheduler_trigger", f"job:{job_id}")
return {"ok": True, "job_id": job_id}
# ------------------------------------------------------------------
# GET /api/admin/system
# ------------------------------------------------------------------
@router.get("/system")
async def system_info(user=Depends(require_admin)):
# DB-Größe
try:
db_size_mb = os.path.getsize(DB_PATH) / 1024 ** 2
except OSError:
db_size_mb = 0.0
# Media-Größe (rekursiv)
media_dir = os.getenv("MEDIA_DIR", "/data/media")
media_size_bytes = 0
try:
for dirpath, _dirs, files in os.walk(media_dir):
for fname in files:
try:
media_size_bytes += os.path.getsize(os.path.join(dirpath, fname))
except OSError:
pass
except OSError:
pass
media_size_mb = media_size_bytes / 1024 ** 2
# Disk-Info
disk_total_gb = 0.0
disk_free_gb = 0.0
try:
st = os.statvfs(DB_PATH)
disk_total_gb = st.f_blocks * st.f_frsize / 1024 ** 3
disk_free_gb = st.f_bavail * st.f_frsize / 1024 ** 3
except (OSError, AttributeError):
pass
return {
"db_size_mb": round(db_size_mb, 2),
"media_size_mb": round(media_size_mb, 2),
"uptime_seconds": int(time.time() - _start_time),
"python_version": sys.version.split()[0],
"disk_total_gb": round(disk_total_gb, 2),
"disk_free_gb": round(disk_free_gb, 2),
}
# ------------------------------------------------------------------
# GET /api/admin/logs
# ------------------------------------------------------------------
@router.get("/logs")
async def get_logs(lines: int = 200, level: str = "", user=Depends(require_admin)):
from main import log_buffer
entries = list(log_buffer)
if level:
entries = [e for e in entries if e['l'] == level.upper()]
return entries[-lines:]
# ------------------------------------------------------------------
# GET /api/admin/audit
# ------------------------------------------------------------------
@router.get("/audit")
async def audit_log(limit: int = 50, user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"""
SELECT id, admin_id, admin_name, action, target, detail, created_at
FROM admin_audit
ORDER BY id DESC
LIMIT ?
""",
(min(limit, 200),),
).fetchall()
return [dict(r) for r in rows]