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:
rene 2026-04-17 23:21:48 +02:00
parent 5927d384bf
commit 92620c2c52
14 changed files with 1035 additions and 46 deletions

View file

@ -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]

View file

@ -0,0 +1,90 @@
"""BAN YARO — Notification Center Routes"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# GET /api/notifications — letzte 50 Benachrichtigungen des Users
# ------------------------------------------------------------------
@router.get("")
async def list_notifications(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT id, type, title, body, data, read_at, created_at
FROM notifications
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50""",
(user["id"],),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/notifications/unread-count — Anzahl ungelesener Einträge
# ------------------------------------------------------------------
@router.get("/unread-count")
async def unread_count(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL",
(user["id"],),
).fetchone()
return {"count": row[0]}
# ------------------------------------------------------------------
# PATCH /api/notifications/read-all — alle als gelesen markieren
# ------------------------------------------------------------------
@router.patch("/read-all")
async def read_all(user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""UPDATE notifications
SET read_at = datetime('now')
WHERE user_id = ? AND read_at IS NULL""",
(user["id"],),
)
return {"ok": True}
# ------------------------------------------------------------------
# PATCH /api/notifications/{id}/read — einzelne als gelesen markieren
# ------------------------------------------------------------------
@router.patch("/{notif_id}/read")
async def read_one(notif_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM notifications WHERE id=? AND user_id=?",
(notif_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Benachrichtigung nicht gefunden.")
conn.execute(
"UPDATE notifications SET read_at = datetime('now') WHERE id=?",
(notif_id,),
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/notifications/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{notif_id}")
async def delete_one(notif_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM notifications WHERE id=? AND user_id=?",
(notif_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Benachrichtigung nicht gefunden.")
conn.execute("DELETE FROM notifications WHERE id=?", (notif_id,))
return {"ok": True}

View file

@ -104,7 +104,7 @@ def send_push(subscription_row, payload: dict) -> bool:
def send_push_to_user(user_id: int, payload: dict):
"""Schickt Push an alle Subscriptions eines Users."""
"""Schickt Push an alle Subscriptions eines Users und speichert Notification in DB."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM push_subscriptions WHERE user_id=?", (user_id,)
@ -113,6 +113,20 @@ def send_push_to_user(user_id: int, payload: dict):
for row in rows:
if send_push(row, payload):
sent += 1
# Notification in DB persistieren (unabhängig vom Push-Versand)
notif_type = payload.get("type", "info")
notif_title = payload.get("title", "Benachrichtigung")
notif_body = payload.get("body") or payload.get("message")
notif_data = json.dumps({k: v for k, v in payload.items()
if k not in ("type", "title", "body", "message")}) or None
with db() as conn:
conn.execute(
"""INSERT INTO notifications (user_id, type, title, body, data)
VALUES (?, ?, ?, ?, ?)""",
(user_id, notif_type, notif_title, notif_body, notif_data),
)
return sent