banyaro/backend/routes/admin.py

1051 lines
43 KiB
Python

"""BAN YARO — Admin / Moderator Backend"""
import asyncio
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
# ------------------------------------------------------------------
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
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
subscription_tier: Optional[str] = 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/action-items
# ------------------------------------------------------------------
@router.get("/action-items")
async def action_items(user=Depends(require_mod)):
with db() as conn:
jobs = conn.execute(
"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')"
).fetchone()[0]
breeders = conn.execute(
"SELECT COUNT(*) FROM users WHERE breeder_status='pending'"
).fetchone()[0]
reports = conn.execute(
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
).fetchone()[0]
fotos = conn.execute(
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
).fetchone()[0]
poi_edits = conn.execute(
"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
).fetchone()[0]
users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0]
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
"reports_open": reports,
"fotos_pending": fotos,
"poi_edits_pending": poi_edits,
"users_today": users_today,
}
# ------------------------------------------------------------------
# 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.is_founder, u.is_partner, u.founder_number,
u.created_at, u.last_login, u.subscription_tier,
(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 + Privileg-Flags 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.")
if data.is_moderator is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
if data.is_social_media is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
if data.subscription_tier is not None and user["rolle"] != "admin":
raise HTTPException(403, "subscription_tier darf nur von Admins geändert werden.")
if data.subscription_tier is not None and data.subscription_tier not in _VALID_TIERS:
raise HTTPException(400, f"Ungültiger Tier. Erlaubt: {', '.join(sorted(_VALID_TIERS))}")
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, subscription_tier 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']}")
if "subscription_tier" in updates:
detail_parts.append(f"Tier→{updates['subscription_tier']}")
_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/ki/history — 30-Tage-Verlauf + Top-User (all-time)
# ------------------------------------------------------------------
@router.get("/ki/history")
async def ki_history(user=Depends(require_mod)):
with db() as conn:
daily = conn.execute("""
SELECT date,
SUM(count) AS total,
SUM(CASE WHEN source='cloud' THEN count ELSE 0 END) AS cloud,
SUM(CASE WHEN source='local' THEN count ELSE 0 END) AS local,
SUM(CASE WHEN source='luna' THEN count ELSE 0 END) AS luna
FROM ki_daily_calls
WHERE date >= DATE('now', '-29 days')
GROUP BY date ORDER BY date ASC
""").fetchall()
top_users = conn.execute("""
SELECT u.name, u.email,
SUM(k.count) AS total,
SUM(CASE WHEN k.source='cloud' THEN k.count ELSE 0 END) AS cloud,
MAX(k.date) AS last_date
FROM ki_daily_calls k JOIN users u ON u.id = k.user_id
GROUP BY k.user_id ORDER BY total DESC LIMIT 15
""").fetchall()
return {
"daily_history": [{"date": r["date"], "total": r["total"],
"cloud": r["cloud"], "local": r["local"], "luna": r["luna"]}
for r in daily],
"top_users": [{"name": r["name"], "email": r["email"],
"total": r["total"], "cloud": r["cloud"], "last_date": r["last_date"]}
for r in top_users],
}
# ------------------------------------------------------------------
# GET /api/admin/ki/status — lokale LLM-Erreichbarkeit prüfen
# ------------------------------------------------------------------
@router.get("/ki/status")
async def ki_status(user=Depends(require_mod)):
import httpx
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
result = {
"mode": KI_MODE,
"local_url": LOCAL_BASE_URL if KI_MODE != "off" else None,
"local_model_config": LOCAL_MODEL,
"local_reachable": False,
"local_model_loaded": None,
"cloud_model": CLOUD_MODEL,
"cloud_key_set": bool(ANTHROPIC_KEY),
}
if KI_MODE != "off":
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"{LOCAL_BASE_URL}/models")
if resp.status_code == 200:
data = resp.json()
models = data.get("data", [])
result["local_reachable"] = True
if models:
result["local_model_loaded"] = models[0].get("id")
except Exception:
pass
return result
# ------------------------------------------------------------------
# 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)
month_start = today_start - timedelta(days=29)
year_start = today_start - timedelta(days=364)
now_ms = int(now.timestamp() * 1000)
today_ms = int(today_start.timestamp() * 1000)
week_ms = int(week_start.timestamp() * 1000)
month_ms = int(month_start.timestamp() * 1000)
year_ms = int(year_start.timestamp() * 1000)
async with httpx.AsyncClient(timeout=15) as c:
r_today, r_week, r_month, r_pv_month, r_pv_year, r_pages, r_refs = await asyncio.gather(
c.get(f"{url}/api/websites/{site_id}/stats",
params={"startAt": today_ms, "endAt": now_ms}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/stats",
params={"startAt": week_ms, "endAt": now_ms}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/stats",
params={"startAt": month_ms, "endAt": now_ms}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/pageviews",
params={"startAt": month_ms, "endAt": now_ms,
"unit": "day", "timezone": "Europe/Berlin"}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/pageviews",
params={"startAt": year_ms, "endAt": now_ms,
"unit": "month", "timezone": "Europe/Berlin"}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/metrics",
params={"startAt": month_ms, "endAt": now_ms,
"type": "url", "limit": 10}, headers=headers),
c.get(f"{url}/api/websites/{site_id}/metrics",
params={"startAt": month_ms, "endAt": now_ms,
"type": "referrer", "limit": 8}, headers=headers),
)
# Monatliche Neuanmeldungen aus lokaler DB (letzte 12 Monate)
with db() as conn:
reg_rows = conn.execute("""
SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
FROM users
WHERE created_at >= date('now', '-12 months')
GROUP BY month ORDER BY month ASC
""").fetchall()
monthly_registrations = [{"month": r["month"], "count": r["count"]} for r in reg_rows]
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(),
"month": r_month.json(),
"pageviews": r_pv_month.json(),
"pageviews_year": r_pv_year.json(),
"monthly_registrations": monthly_registrations,
"top_pages": _to_list(r_pages),
"referrers": _to_list(r_refs),
}
# ------------------------------------------------------------------
# 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.get("/ors/stats")
async def ors_stats(user=Depends(require_mod)):
"""ORS-Routenvorschlag Statistiken für Admin-Panel."""
with db() as conn:
# Heute
today = __import__('datetime').date.today().isoformat()
today_row = conn.execute(
"SELECT COALESCE(count,0) as count FROM ors_daily_total WHERE date=?", (today,)
).fetchone()
today_count = today_row["count"] if today_row else 0
# Letzte 30 Tage (Verlauf)
daily_history = conn.execute("""
SELECT date, count FROM ors_daily_total
WHERE date >= DATE('now', '-29 days')
ORDER BY date ASC
""").fetchall()
# Letzte 8 Wochen (Wochensummen aus route_suggest_usage)
weekly_totals = conn.execute("""
SELECT week, SUM(count) as count
FROM route_suggest_usage
WHERE week >= DATE('now', '-56 days')
GROUP BY week ORDER BY week ASC
""").fetchall()
# Top-Nutzer (alle Zeiten)
top_users = conn.execute("""
SELECT u.name, u.email,
SUM(r.count) as total,
MAX(r.week) as last_week
FROM route_suggest_usage r
JOIN users u ON u.id = r.user_id
GROUP BY r.user_id
ORDER BY total DESC
LIMIT 15
""").fetchall()
return {
"today_count": today_count,
"today_limit": 2000,
"daily_history": [{"date": r["date"], "count": r["count"]} for r in daily_history],
"weekly_totals": [{"week": r["week"], "count": r["count"]} for r in weekly_totals],
"top_users": [{"name": r["name"], "email": r["email"],
"total": r["total"], "last_week": r["last_week"]} for r in top_users],
}
@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}