diff --git a/backend/database.py b/backend/database.py index ae19c35..11e9efb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -642,6 +642,21 @@ def _migrate(conn_factory): """) logger.info("Migration: diary_dogs Backfill abgeschlossen.") + # Benachrichtigungs-Center + conn.executescript(""" + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT, + data TEXT, + read_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC); + """) + # Hund-Teilen: Einladungssystem conn.executescript(""" CREATE TABLE IF NOT EXISTS dog_shares ( diff --git a/backend/main.py b/backend/main.py index 3e39cdd..0fa1980 100644 --- a/backend/main.py +++ b/backend/main.py @@ -75,7 +75,8 @@ from routes.webcal import router as webcal_router from routes.profile import router as profile_router from routes.import_data import router as import_router from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router -from routes.widget import router as widget_router +from routes.widget import router as widget_router +from routes.notifications import router as notifications_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -105,6 +106,7 @@ app.include_router(import_router, prefix="/api/import", tags=["Import" app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"]) app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"]) app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) +app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) # ------------------------------------------------------------------ diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 28f6e2d..2ddfa21 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -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] diff --git a/backend/routes/notifications.py b/backend/routes/notifications.py new file mode 100644 index 0000000..0990748 --- /dev/null +++ b/backend/routes/notifications.py @@ -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} diff --git a/backend/routes/push.py b/backend/routes/push.py index 992ba73..dd11fbc 100644 --- a/backend/routes/push.py +++ b/backend/routes/push.py @@ -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 diff --git a/backend/static/css/components.css b/backend/static/css/components.css index cc27dc0..21e82de 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1827,6 +1827,42 @@ textarea.form-control { gap: var(--space-2); } .rk-card-author { font-size: var(--text-xs); color: var(--c-text-muted); } +.rk-card-creator { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + font-weight: 600; + color: var(--c-primary); + margin-bottom: 2px; +} +/* Mode-Toggle: Meine Routen / Entdecken */ +.rk-mode-toggle { + display: flex; + gap: 0; + margin-bottom: var(--space-3); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + overflow: hidden; + align-self: flex-start; +} +.rk-mode-btn { + flex: 1; + padding: 6px 16px; + font-size: var(--text-sm); + font-weight: 500; + background: var(--c-bg); + color: var(--c-text-secondary); + border: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.rk-mode-btn.active { + background: var(--c-primary); + color: #fff; +} +.rk-mode-btn:first-child { border-right: 1px solid var(--c-border); } .rk-dl-btn { font-size: var(--text-xs); padding: 4px 8px; diff --git a/backend/static/index.html b/backend/static/index.html index 375aca8..8f225c4 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -22,8 +22,8 @@ - - + +
@@ -68,6 +68,10 @@ Nachrichten +