860 lines
35 KiB
Python
860 lines
35 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()
|
|
|
|
# Umami token cache
|
|
_umami_token: dict = {"token": None, "expires": 0.0}
|
|
|
|
|
|
async def _umami_auth() -> str:
|
|
global _umami_token
|
|
if _umami_token["token"] and time.time() < _umami_token["expires"]:
|
|
return _umami_token["token"]
|
|
import httpx
|
|
url = os.getenv("UMAMI_URL", "").rstrip("/")
|
|
resp = await httpx.AsyncClient().post(
|
|
f"{url}/api/auth/login",
|
|
json={"username": os.getenv("UMAMI_USERNAME"), "password": os.getenv("UMAMI_PASSWORD")},
|
|
timeout=10,
|
|
)
|
|
resp.raise_for_status()
|
|
tok = resp.json()["token"]
|
|
_umami_token = {"token": tok, "expires": time.time() + 23 * 3600}
|
|
return tok
|
|
|
|
# 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
|
|
is_social_media: Optional[int] = None
|
|
|
|
class WikiEnrichBody(BaseModel):
|
|
limit: int = 10
|
|
|
|
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()
|
|
}
|
|
|
|
# KI-Nutzung
|
|
try:
|
|
from ki import CLOUD_WEEKLY_LIMIT
|
|
ki_today = conn.execute(
|
|
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date=DATE('now')"
|
|
).fetchone()[0]
|
|
ki_week = conn.execute(
|
|
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','-6 days')"
|
|
).fetchone()[0]
|
|
ki_month = conn.execute(
|
|
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','start of month')"
|
|
).fetchone()[0]
|
|
ki_users_today = conn.execute(
|
|
"SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')"
|
|
).fetchone()[0]
|
|
# Aufschlüsselung nach Quelle (diese Woche)
|
|
_src_week = {
|
|
r[0]: r[1] for r in conn.execute(
|
|
"SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls "
|
|
"WHERE date>=DATE('now','-6 days') GROUP BY source"
|
|
).fetchall()
|
|
}
|
|
ki_cloud_week = _src_week.get("cloud", 0)
|
|
ki_local_week = _src_week.get("local", 0)
|
|
ki_luna_week = _src_week.get("luna", 0)
|
|
# Top-User Cloud diese Woche
|
|
ki_top_users = [
|
|
{"user_id": r[0], "name": r[1], "cloud_calls": r[2]} for r in conn.execute(
|
|
"""SELECT k.user_id, u.name, SUM(k.count) as n
|
|
FROM ki_daily_calls k JOIN users u ON u.id=k.user_id
|
|
WHERE k.source='cloud' AND k.date>=DATE('now','-6 days')
|
|
GROUP BY k.user_id ORDER BY n DESC LIMIT 10"""
|
|
).fetchall()
|
|
]
|
|
except Exception:
|
|
from ki import CLOUD_WEEKLY_LIMIT
|
|
ki_today = ki_week = ki_month = ki_users_today = 0
|
|
ki_cloud_week = ki_local_week = ki_luna_week = 0
|
|
ki_top_users = []
|
|
|
|
# Ausstehende Wiki-Foto-Einreichungen
|
|
try:
|
|
pending_fotos = conn.execute(
|
|
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
|
|
).fetchone()[0]
|
|
except Exception:
|
|
pending_fotos = 0
|
|
|
|
# Social Media Tracking
|
|
try:
|
|
social_total = conn.execute("SELECT COUNT(*) FROM social_content").fetchone()[0]
|
|
social_published = conn.execute(
|
|
"SELECT COUNT(*) FROM social_content WHERE status='published'"
|
|
).fetchone()[0]
|
|
social_scheduled = conn.execute(
|
|
"SELECT COUNT(*) FROM social_content WHERE status='scheduled'"
|
|
).fetchone()[0]
|
|
social_ideas = conn.execute(
|
|
"SELECT COUNT(*) FROM social_content WHERE status='idea'"
|
|
).fetchone()[0]
|
|
social_this_week = conn.execute(
|
|
"SELECT COUNT(*) FROM social_content WHERE status='published' "
|
|
"AND published_at >= datetime('now', '-7 days')"
|
|
).fetchone()[0]
|
|
social_by_cat = {
|
|
row[0]: row[1] for row in conn.execute(
|
|
"SELECT category, COUNT(*) FROM social_content "
|
|
"WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC"
|
|
).fetchall()
|
|
}
|
|
social_recent = [dict(r) for r in conn.execute(
|
|
"""SELECT topic, status, platform, format, created_at,
|
|
published_at, category, ai_score
|
|
FROM social_content
|
|
ORDER BY created_at DESC LIMIT 10"""
|
|
).fetchall()]
|
|
except Exception:
|
|
social_total = social_published = social_scheduled = 0
|
|
social_ideas = social_this_week = 0
|
|
social_by_cat = {}
|
|
social_recent = []
|
|
|
|
return {
|
|
"users_total": users_total,
|
|
"users_today": users_today,
|
|
"threads": threads,
|
|
"posts": posts,
|
|
"open_reports": open_reports,
|
|
"pending_fotos": pending_fotos,
|
|
"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,
|
|
"ki_today": ki_today,
|
|
"ki_week": ki_week,
|
|
"ki_month": ki_month,
|
|
"ki_users_today": ki_users_today,
|
|
"ki_cloud_week": ki_cloud_week,
|
|
"ki_local_week": ki_local_week,
|
|
"ki_luna_week": ki_luna_week,
|
|
"ki_cloud_weekly_limit": CLOUD_WEEKLY_LIMIT,
|
|
"ki_top_users": ki_top_users,
|
|
"social_total": social_total,
|
|
"social_published": social_published,
|
|
"social_scheduled": social_scheduled,
|
|
"social_ideas": social_ideas,
|
|
"social_this_week": social_this_week,
|
|
"social_by_cat": social_by_cat,
|
|
"social_recent": social_recent,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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)
|
|
|
|
# E-Mail nur für Admins; Moderatoren sehen maskierte Version
|
|
_email_col = "u.email" if user["rolle"] == "admin" else \
|
|
"SUBSTR(u.email,1,2)||'***@'||SUBSTR(u.email,INSTR(u.email,'@')+1) AS email"
|
|
rows = conn.execute(f"""
|
|
SELECT u.id, u.name, {_email_col}, 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,
|
|
ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km,
|
|
(SELECT COUNT(*) FROM routes r WHERE r.user_id=u.id) AS route_count,
|
|
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=u.id) AS poi_count,
|
|
(SELECT MAX(r.created_at) FROM routes r WHERE r.user_id=u.id) AS last_route
|
|
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
|
|
|
|
# SW-Cache-Version aus sw.js lesen
|
|
sw_version = "?"
|
|
try:
|
|
import re as _re
|
|
static_dir = os.getenv("STATIC_DIR", "/app/static")
|
|
sw_content = open(os.path.join(static_dir, "sw.js")).readline()
|
|
m = _re.search(r"'(by-v\d+)'", sw_content)
|
|
if m:
|
|
sw_version = m.group(1)
|
|
except Exception:
|
|
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),
|
|
"sw_version": sw_version,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/analytics — Umami-Proxy
|
|
# ------------------------------------------------------------------
|
|
@router.get("/analytics")
|
|
async def get_analytics(user=Depends(require_mod)):
|
|
import httpx
|
|
from datetime import timedelta
|
|
url = os.getenv("UMAMI_URL", "").rstrip("/")
|
|
site_id = os.getenv("UMAMI_SITE_ID", "")
|
|
if not url or not site_id:
|
|
raise HTTPException(503, "Umami nicht konfiguriert.")
|
|
token = await _umami_auth()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
now = datetime.now(_TZ)
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
week_start = today_start - timedelta(days=6)
|
|
now_ms = int(now.timestamp() * 1000)
|
|
today_ms = int(today_start.timestamp() * 1000)
|
|
week_ms = int(week_start.timestamp() * 1000)
|
|
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r_today = await c.get(f"{url}/api/websites/{site_id}/stats",
|
|
params={"startAt": today_ms, "endAt": now_ms}, headers=headers)
|
|
r_week = await c.get(f"{url}/api/websites/{site_id}/stats",
|
|
params={"startAt": week_ms, "endAt": now_ms}, headers=headers)
|
|
r_pv = await c.get(f"{url}/api/websites/{site_id}/pageviews",
|
|
params={"startAt": week_ms, "endAt": now_ms,
|
|
"unit": "day", "timezone": "Europe/Berlin"}, headers=headers)
|
|
r_pages = await c.get(f"{url}/api/websites/{site_id}/metrics",
|
|
params={"startAt": week_ms, "endAt": now_ms,
|
|
"type": "url", "limit": 8}, headers=headers)
|
|
|
|
def _to_list(r):
|
|
j = r.json()
|
|
if isinstance(j, list):
|
|
return j
|
|
if isinstance(j, dict):
|
|
return j.get("data", j.get("metrics", []))
|
|
return []
|
|
|
|
return {
|
|
"today": r_today.json(),
|
|
"week": r_week.json(),
|
|
"pageviews": r_pv.json(),
|
|
"top_pages": _to_list(r_pages),
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/wiki/enrich — KI-Rassen-Anreicherung anstoßen
|
|
# ------------------------------------------------------------------
|
|
@router.post("/wiki/enrich")
|
|
async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
|
|
from scraper.breed_enricher import enrich_breeds
|
|
limit = max(1, min(data.limit, 100))
|
|
enriched = await enrich_breeds(limit)
|
|
with db() as conn:
|
|
remaining = conn.execute(
|
|
"SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=0"
|
|
).fetchone()[0]
|
|
return {"enriched": enriched, "remaining": remaining}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/wiki/evaluate — LLM-as-Judge Qualitätsbewertung
|
|
# ------------------------------------------------------------------
|
|
@router.get("/wiki/evaluate")
|
|
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
|
|
from scraper.breed_evaluator import evaluate_enrichment
|
|
sample = max(5, min(sample, 50))
|
|
return await evaluate_enrichment(sample_size=sample)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/wiki/enrichment-status — Enrichment-Statistik
|
|
# ------------------------------------------------------------------
|
|
@router.get("/wiki/enrichment-status")
|
|
async def wiki_enrichment_status(user=Depends(require_mod)):
|
|
with db() as conn:
|
|
total = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
|
|
enriched = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
|
|
no_wiki = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=2").fetchone()[0]
|
|
pending = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=0").fetchone()[0]
|
|
by_model = {
|
|
row[0] or "unbekannt": row[1]
|
|
for row in conn.execute(
|
|
"SELECT ki_model, COUNT(*) FROM wiki_rassen "
|
|
"WHERE ki_enriched=1 GROUP BY ki_model ORDER BY 2 DESC"
|
|
).fetchall()
|
|
}
|
|
with_photo = conn.execute(
|
|
"SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url != ''"
|
|
).fetchone()[0]
|
|
return {
|
|
"total": total,
|
|
"enriched": enriched,
|
|
"no_wiki": no_wiki,
|
|
"pending": pending,
|
|
"with_photo": with_photo,
|
|
"by_model": by_model,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/wiki/fetch-photos — Wiki-Fotos laden
|
|
# ------------------------------------------------------------------
|
|
@router.post("/wiki/fetch-photos")
|
|
async def wiki_fetch_photos(limit: int = 50, user=Depends(require_mod)):
|
|
import asyncio, subprocess
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"python3", "/app/scraper/fetch_wiki_images.py", "--limit", str(limit),
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
lines = (stdout + stderr).decode()
|
|
found = lines.count("Foto gespeichert")
|
|
return {"launched": True, "found": found, "log": lines[-2000:]}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
|
|
# ------------------------------------------------------------------
|
|
@router.get("/social")
|
|
async def admin_social_stats(user=Depends(require_mod)):
|
|
"""Social-Media-Übersicht für Admins — alle Manager, alle Plattformen."""
|
|
with db() as conn:
|
|
# Pro Manager: Name + Counts
|
|
managers = conn.execute("""
|
|
SELECT u.id, u.name,
|
|
COUNT(sc.id) AS total,
|
|
COUNT(CASE WHEN sc.status='published' THEN 1 END) AS published,
|
|
COUNT(CASE WHEN sc.status='idea' THEN 1 END) AS ideas,
|
|
COUNT(CASE WHEN sc.status='scheduled' THEN 1 END) AS scheduled,
|
|
COUNT(CASE WHEN sc.post_url IS NOT NULL
|
|
AND sc.post_url != ''
|
|
AND sc.status='published' THEN 1 END) AS with_link
|
|
FROM users u
|
|
JOIN social_content sc ON sc.created_by = u.id
|
|
GROUP BY u.id
|
|
ORDER BY published DESC
|
|
""").fetchall()
|
|
|
|
# Veröffentlichte Posts nach Plattform
|
|
by_platform = conn.execute("""
|
|
SELECT platform, COUNT(*) AS n
|
|
FROM social_content
|
|
WHERE status='published'
|
|
GROUP BY platform
|
|
ORDER BY n DESC
|
|
""").fetchall()
|
|
|
|
# Veröffentlichte Posts nach Monat (letzte 6 Monate)
|
|
by_month = conn.execute("""
|
|
SELECT strftime('%Y-%m', published_at) AS monat, COUNT(*) AS n
|
|
FROM social_content
|
|
WHERE status='published' AND published_at IS NOT NULL
|
|
GROUP BY monat
|
|
ORDER BY monat DESC
|
|
LIMIT 6
|
|
""").fetchall()
|
|
|
|
# Letzte veröffentlichte Posts mit Link (für Abrechnung/Nachweis)
|
|
recent_published = conn.execute("""
|
|
SELECT sc.id, sc.topic, sc.platform, sc.category,
|
|
sc.published_at, sc.post_url, sc.ai_score,
|
|
u.name AS manager
|
|
FROM social_content sc
|
|
LEFT JOIN users u ON u.id = sc.created_by
|
|
WHERE sc.status = 'published'
|
|
ORDER BY sc.published_at DESC
|
|
LIMIT 50
|
|
""").fetchall()
|
|
|
|
return {
|
|
"managers": [dict(r) for r in managers],
|
|
"by_platform": [dict(r) for r in by_platform],
|
|
"by_month": [dict(r) for r in by_month],
|
|
"recent_published": [dict(r) for r in recent_published],
|
|
}
|
|
|
|
|
|
@router.delete("/wiki/zuchter/{zuchter_id}", status_code=204)
|
|
async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Züchter nicht gefunden.")
|
|
conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,))
|
|
_audit(conn, user, "wiki_zuchter_delete", f"zuchter:{zuchter_id}")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/media/generate-previews — Previews für Bestandsmedien
|
|
# ------------------------------------------------------------------
|
|
@router.post("/media/generate-previews")
|
|
async def generate_media_previews(user=Depends(require_admin)):
|
|
"""Generiert fehlende _preview.jpg für alle Bilder in /data/media."""
|
|
import io as _io
|
|
from media_utils import generate_preview, _PREVIEW_EXTS
|
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|
|
|
generated = 0
|
|
skipped = 0
|
|
errors = 0
|
|
|
|
for subdir in ("diary", "forum"):
|
|
folder = os.path.join(MEDIA_DIR, subdir)
|
|
if not os.path.isdir(folder):
|
|
continue
|
|
for fname in os.listdir(folder):
|
|
# Nur Original-Bilder (keine _preview, _thumb, Videos, PDFs)
|
|
low = fname.lower()
|
|
if "_preview" in low or "_thumb" in low:
|
|
continue
|
|
base, ext = os.path.splitext(fname)
|
|
if ext.lower() not in _PREVIEW_EXTS:
|
|
continue
|
|
preview_path = os.path.join(folder, base + "_preview.webp")
|
|
if os.path.exists(preview_path):
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
data = open(os.path.join(folder, fname), "rb").read()
|
|
preview = generate_preview(data, ext)
|
|
if preview:
|
|
open(preview_path, "wb").write(preview)
|
|
generated += 1
|
|
else:
|
|
skipped += 1
|
|
except Exception as exc:
|
|
errors += 1
|
|
|
|
return {"generated": generated, "skipped": skipped, "errors": errors}
|