Sprint 18: Notification Center, Routen entdecken, Onboarding, Admin-Erweiterungen
- Notifications: History-Tabelle, /api/notifications Endpoints, push.py schreibt in DB - Notifications: Page (notifications.js) mit Badge, Typen-Icons, gelesen-Markierung - Routen: Entdecken-Modus mit Ersteller-Anzeige, Nearby-Filter, Mine/Discover Toggle - Onboarding: Willkommens-Modal nach Registrierung, Push-Angebot nach Login - Admin: Scheduler-Tab (Jobs anzeigen + manuell triggern), System-Health (DB/Disk/Uptime) - Admin: Audit-Log (wer hat was wann gemacht), erweiterte Stats (Push-Abos, aktive User, Routen) - SW: by-v152, APP_VER 125
This commit is contained in:
parent
5927d384bf
commit
92620c2c52
14 changed files with 1035 additions and 46 deletions
|
|
@ -1,12 +1,47 @@
|
|||
"""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
|
||||
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
|
||||
|
|
@ -60,17 +95,42 @@ async def stats(user=Depends(require_mod)):
|
|||
"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]
|
||||
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]
|
||||
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -126,7 +186,7 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
|
|||
raise HTTPException(400, "Ungültige Rolle.")
|
||||
|
||||
with db() as conn:
|
||||
target = conn.execute("SELECT id, rolle FROM users WHERE id=?", (uid,)).fetchone()
|
||||
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
|
||||
|
|
@ -148,6 +208,14 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
|
|||
(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)
|
||||
|
||||
|
||||
|
|
@ -157,12 +225,13 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
|
|||
@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 FROM users WHERE id=?", (uid,)).fetchone()
|
||||
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']})")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -210,13 +279,15 @@ async def admin_threads(
|
|||
@router.patch("/forum/threads/{tid}")
|
||||
async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(require_mod)):
|
||||
with db() as conn:
|
||||
if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone():
|
||||
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}
|
||||
|
||||
|
||||
|
|
@ -226,9 +297,11 @@ async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(requ
|
|||
@router.delete("/forum/threads/{tid}", status_code=204)
|
||||
async def admin_delete_thread(tid: int, user=Depends(require_mod)):
|
||||
with db() as conn:
|
||||
if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone():
|
||||
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]})")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -245,6 +318,7 @@ async def admin_delete_post(pid: int, user=Depends(require_mod)):
|
|||
"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']}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -277,8 +351,106 @@ async def admin_resolve_report(rid: int, user=Depends(require_mod)):
|
|||
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=?",
|
||||
(0 if r["resolved"] else 1, rid)
|
||||
(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/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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue