Audit-Log + Brute-Force-Schutz + unlimited_budget + User-Delete-Fix
- Schema-Migration: ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id, network_analyses.created_by (behebt 500er beim User-Loeschen). Neue Spalte licenses.unlimited_budget. Neue Tabellen portal_audit_log, portal_login_attempts. - Audit-Log: alle CREATE/UPDATE/DELETE auf Org/User/Lizenz/Quelle + Login-Events werden mit before/after-Diff in portal_audit_log geschrieben. - Brute-Force-Schutz: 5 Fehlversuche pro IP+Username/15min -> 429 mit Retry-After. - Token-Budget: expliziter Schalter unlimited_budget pro Lizenz. UI zeigt ehrlich >100%-Verbrauch (kein Math.min mehr) und ungebremste Anzeige bei unlimited. - Neuer Audit-Log Tab mit Filter (Aktion/Ressource/Admin/Zeitraum) und Pagination.
Dieser Commit ist enthalten in:
251
migrations/migrate_2026-05-02.py
Normale Datei
251
migrations/migrate_2026-05-02.py
Normale Datei
@@ -0,0 +1,251 @@
|
|||||||
|
"""Migration 2026-05-02: User-Delete-Fix + unlimited_budget + Audit/Brute-Force Tabellen.
|
||||||
|
|
||||||
|
Aenderungen:
|
||||||
|
(A) ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id, network_analyses.created_by
|
||||||
|
(B) Neue Spalte licenses.unlimited_budget (mit Backfill)
|
||||||
|
(C) Neue Tabellen portal_audit_log, portal_login_attempts
|
||||||
|
|
||||||
|
Idempotent: jeder Schritt prueft, ob er bereits ausgefuehrt wurde.
|
||||||
|
Vorher: Backup der DB anlegen!
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DB_PATH = "/home/claude-dev/osint-data/osint.db"
|
||||||
|
|
||||||
|
|
||||||
|
def has_on_delete_set_null(db, table, column):
|
||||||
|
"""Prueft, ob die ON DELETE SET NULL Klausel bereits gesetzt ist."""
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
sql = row[0]
|
||||||
|
return "ON DELETE SET NULL" in sql.upper() and column in sql
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(db, table, column):
|
||||||
|
cols = [r[1] for r in db.execute("PRAGMA table_info(" + table + ")").fetchall()]
|
||||||
|
return column in cols
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(db, name):
|
||||||
|
return db.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_table(db, name, new_create_sql, indexes):
|
||||||
|
"""Recreate-Pattern: alte Spalten in neue Tabelle kopieren."""
|
||||||
|
print(" - Recreate " + name + "...")
|
||||||
|
cols_old = [r[1] for r in db.execute("PRAGMA table_info(" + name + ")").fetchall()]
|
||||||
|
db.execute(new_create_sql.replace("TABLE " + name, "TABLE " + name + "_new"))
|
||||||
|
cols_new = [r[1] for r in db.execute("PRAGMA table_info(" + name + "_new)").fetchall()]
|
||||||
|
common = [c for c in cols_old if c in cols_new]
|
||||||
|
cols_csv = ", ".join(common)
|
||||||
|
db.execute("INSERT INTO " + name + "_new (" + cols_csv + ") SELECT " + cols_csv + " FROM " + name)
|
||||||
|
db.execute("DROP TABLE " + name)
|
||||||
|
db.execute("ALTER TABLE " + name + "_new RENAME TO " + name)
|
||||||
|
for idx_sql in indexes:
|
||||||
|
db.execute(idx_sql)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
db = sqlite3.connect(DB_PATH)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
counts_before = {}
|
||||||
|
for t in ["incidents", "magic_links", "network_analyses", "licenses",
|
||||||
|
"users", "organizations", "sources"]:
|
||||||
|
counts_before[t] = db.execute("SELECT COUNT(*) FROM " + t).fetchone()[0]
|
||||||
|
print("Counts vor Migration:")
|
||||||
|
for t, n in counts_before.items():
|
||||||
|
print(" " + t + ": " + str(n))
|
||||||
|
print()
|
||||||
|
|
||||||
|
db.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
db.execute("BEGIN")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("(A) ON DELETE SET NULL fuer 3 Tabellen")
|
||||||
|
|
||||||
|
if not has_on_delete_set_null(db, "incidents", "created_by"):
|
||||||
|
recreate_table(
|
||||||
|
db, "incidents",
|
||||||
|
"""CREATE TABLE incidents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
refresh_mode TEXT DEFAULT 'manual',
|
||||||
|
refresh_interval INTEGER DEFAULT 15,
|
||||||
|
retention_days INTEGER DEFAULT 0,
|
||||||
|
summary TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
type TEXT DEFAULT 'adhoc',
|
||||||
|
sources_json TEXT,
|
||||||
|
international_sources INTEGER DEFAULT 1,
|
||||||
|
visibility TEXT DEFAULT 'public',
|
||||||
|
tenant_id INTEGER REFERENCES organizations(id),
|
||||||
|
notify_email_summary INTEGER DEFAULT 0,
|
||||||
|
notify_email_contradiction INTEGER DEFAULT 0,
|
||||||
|
notify_email_status_change INTEGER DEFAULT 0,
|
||||||
|
include_telegram INTEGER DEFAULT 0,
|
||||||
|
telegram_categories TEXT DEFAULT NULL,
|
||||||
|
category_labels TEXT,
|
||||||
|
executive_summary TEXT,
|
||||||
|
refresh_start_time TEXT,
|
||||||
|
latest_developments TEXT
|
||||||
|
)""",
|
||||||
|
[
|
||||||
|
"CREATE INDEX idx_incidents_tenant_status ON incidents(tenant_id, status)",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - incidents: bereits ON DELETE SET NULL, skip")
|
||||||
|
|
||||||
|
if not has_on_delete_set_null(db, "magic_links", "user_id"):
|
||||||
|
recreate_table(
|
||||||
|
db, "magic_links",
|
||||||
|
"""CREATE TABLE magic_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL DEFAULT 'login',
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
is_used INTEGER DEFAULT 0,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - magic_links: bereits ON DELETE SET NULL, skip")
|
||||||
|
|
||||||
|
if not has_on_delete_set_null(db, "network_analyses", "created_by"):
|
||||||
|
recreate_table(
|
||||||
|
db, "network_analyses",
|
||||||
|
"""CREATE TABLE network_analyses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
entity_count INTEGER DEFAULT 0,
|
||||||
|
relation_count INTEGER DEFAULT 0,
|
||||||
|
data_hash TEXT,
|
||||||
|
last_generated_at TIMESTAMP,
|
||||||
|
tenant_id INTEGER REFERENCES organizations(id),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - network_analyses: bereits ON DELETE SET NULL, skip")
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("(B) licenses.unlimited_budget Spalte")
|
||||||
|
if not column_exists(db, "licenses", "unlimited_budget"):
|
||||||
|
db.execute("ALTER TABLE licenses ADD COLUMN unlimited_budget INTEGER DEFAULT 0")
|
||||||
|
print(" - Spalte angelegt")
|
||||||
|
res = db.execute(
|
||||||
|
"UPDATE licenses SET unlimited_budget=1 "
|
||||||
|
"WHERE credits_total IS NULL OR cost_per_credit IS NULL OR cost_per_credit=0"
|
||||||
|
)
|
||||||
|
print(" - Backfill: " + str(res.rowcount) + " Lizenzen auf unlimited gesetzt")
|
||||||
|
else:
|
||||||
|
print(" - bereits vorhanden, skip")
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("(C) portal_audit_log + portal_login_attempts")
|
||||||
|
if not table_exists(db, "portal_audit_log"):
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE portal_audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
admin_id INTEGER,
|
||||||
|
admin_username TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id INTEGER,
|
||||||
|
before_json TEXT,
|
||||||
|
after_json TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute("CREATE INDEX idx_audit_ts ON portal_audit_log(ts DESC)")
|
||||||
|
db.execute("CREATE INDEX idx_audit_admin ON portal_audit_log(admin_id)")
|
||||||
|
db.execute("CREATE INDEX idx_audit_resource ON portal_audit_log(resource_type, resource_id)")
|
||||||
|
print(" - portal_audit_log angelegt")
|
||||||
|
else:
|
||||||
|
print(" - portal_audit_log bereits vorhanden, skip")
|
||||||
|
|
||||||
|
if not table_exists(db, "portal_login_attempts"):
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE portal_login_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute("CREATE INDEX idx_login_lookup ON portal_login_attempts(ip, username, ts)")
|
||||||
|
print(" - portal_login_attempts angelegt")
|
||||||
|
else:
|
||||||
|
print(" - portal_login_attempts bereits vorhanden, skip")
|
||||||
|
|
||||||
|
db.execute("COMMIT")
|
||||||
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("=== Verifikation ===")
|
||||||
|
ok = True
|
||||||
|
for t, n in counts_before.items():
|
||||||
|
n_after = db.execute("SELECT COUNT(*) FROM " + t).fetchone()[0]
|
||||||
|
status = "OK" if n_after == n else "MISMATCH (" + str(n) + " -> " + str(n_after) + ")"
|
||||||
|
print(" " + t + ": " + str(n_after) + " " + status)
|
||||||
|
if n_after != n:
|
||||||
|
ok = False
|
||||||
|
for t, c in [("incidents", "created_by"), ("magic_links", "user_id"),
|
||||||
|
("network_analyses", "created_by")]:
|
||||||
|
on_del = has_on_delete_set_null(db, t, c)
|
||||||
|
print(" " + t + "." + c + " ON DELETE SET NULL: " + str(on_del))
|
||||||
|
if not on_del:
|
||||||
|
ok = False
|
||||||
|
col_ok = column_exists(db, "licenses", "unlimited_budget")
|
||||||
|
print(" licenses.unlimited_budget: " + str(col_ok))
|
||||||
|
if not col_ok:
|
||||||
|
ok = False
|
||||||
|
for t in ["portal_audit_log", "portal_login_attempts"]:
|
||||||
|
t_ok = table_exists(db, t)
|
||||||
|
print(" Tabelle " + t + ": " + str(t_ok))
|
||||||
|
if not t_ok:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
print("")
|
||||||
|
print("Alle Checks OK")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("")
|
||||||
|
print("FEHLER: Mindestens ein Check fehlgeschlagen")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.execute("ROLLBACK")
|
||||||
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
|
print("")
|
||||||
|
print("FEHLER: " + type(e).__name__ + ": " + str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
106
src/audit.py
Normale Datei
106
src/audit.py
Normale Datei
@@ -0,0 +1,106 @@
|
|||||||
|
"""Audit-Log Helper.
|
||||||
|
|
||||||
|
Schreibt Eintraege in portal_audit_log mit before/after-JSON.
|
||||||
|
Diff-Helper filtert auf veraenderte Felder, um Speicher zu sparen.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger("verwaltung.audit")
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Optional[Request]) -> str:
|
||||||
|
"""IP aus X-Forwarded-For (Nginx) oder direkt aus Connection.
|
||||||
|
|
||||||
|
Nginx setzt X-Forwarded-For mit der echten Client-IP.
|
||||||
|
Falls nicht vorhanden, fallback auf request.client.host.
|
||||||
|
"""
|
||||||
|
if request is None:
|
||||||
|
return ""
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
# Erste IP in der Kette ist die echte Client-IP
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
real = request.headers.get("x-real-ip")
|
||||||
|
if real:
|
||||||
|
return real.strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host or ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def diff(before: Optional[dict], after: Optional[dict]) -> Optional[dict]:
|
||||||
|
"""Filtert auf veraenderte Felder. Bei CREATE/DELETE: voller Datensatz."""
|
||||||
|
if before is None or after is None:
|
||||||
|
return None
|
||||||
|
changes = {}
|
||||||
|
keys = set(before.keys()) | set(after.keys())
|
||||||
|
for k in keys:
|
||||||
|
bv = before.get(k)
|
||||||
|
av = after.get(k)
|
||||||
|
if bv != av:
|
||||||
|
changes[k] = {"old": bv, "new": av}
|
||||||
|
return changes if changes else None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_json(d: Optional[dict]) -> Optional[str]:
|
||||||
|
if d is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.dumps(d, default=str, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("audit JSON-Serialisierung fehlgeschlagen: %s", e)
|
||||||
|
return json.dumps({"_error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def log_action(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
admin: Optional[dict],
|
||||||
|
ip: str,
|
||||||
|
action: str,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
resource_id: Optional[int] = None,
|
||||||
|
before: Optional[dict] = None,
|
||||||
|
after: Optional[dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Schreibt einen Audit-Eintrag.
|
||||||
|
|
||||||
|
action: 'create' | 'update' | 'delete' | 'login_success' | 'login_failed' | 'login_blocked'
|
||||||
|
resource_type: 'organization' | 'user' | 'license' | 'source' | 'admin' | 'auth'
|
||||||
|
"""
|
||||||
|
admin_id = admin.get("id") if admin else None
|
||||||
|
admin_username = admin.get("username") if admin else None
|
||||||
|
|
||||||
|
# Bei UPDATE: nur Diff speichern, sonst vollen Datensatz
|
||||||
|
if action == "update" and before is not None and after is not None:
|
||||||
|
d = diff(before, after)
|
||||||
|
if d is None:
|
||||||
|
# Keine Aenderung -> nicht loggen
|
||||||
|
return
|
||||||
|
before_json = _to_json({k: v["old"] for k, v in d.items()})
|
||||||
|
after_json = _to_json({k: v["new"] for k, v in d.items()})
|
||||||
|
else:
|
||||||
|
before_json = _to_json(before)
|
||||||
|
after_json = _to_json(after)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO portal_audit_log
|
||||||
|
(admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(admin_id, admin_username, ip, action, resource_type, resource_id, before_json, after_json),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("audit log_action fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def row_to_dict(db: aiosqlite.Connection, table: str, row_id: int) -> Optional[dict]:
|
||||||
|
"""Holt Datensatz als Dict (fuer before/after-Snapshot)."""
|
||||||
|
cursor = await db.execute(f"SELECT * FROM {table} WHERE id = ?", (row_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
73
src/main.py
73
src/main.py
@@ -2,7 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status
|
from fastapi import FastAPI, Depends, HTTPException, status, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ from config import STATIC_DIR, PORT
|
|||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
from auth import verify_password, create_token
|
from auth import verify_password, create_token
|
||||||
from models import LoginRequest, TokenResponse
|
from models import LoginRequest, TokenResponse
|
||||||
from routers import organizations, licenses, users, dashboard, sources, token_usage
|
from routers import organizations, licenses, users, dashboard, sources, token_usage, audit
|
||||||
|
from audit import log_action, get_client_ip
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("verwaltung")
|
logger = logging.getLogger("verwaltung")
|
||||||
|
|
||||||
|
# Brute-Force-Schutz
|
||||||
|
MAX_FAILED_ATTEMPTS = 5
|
||||||
|
BLOCK_WINDOW_MINUTES = 15
|
||||||
|
PURGE_AFTER_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -41,25 +47,84 @@ app.include_router(users.router)
|
|||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(sources.router)
|
app.include_router(sources.router)
|
||||||
app.include_router(token_usage.router)
|
app.include_router(token_usage.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
|
||||||
|
|
||||||
# --- Login ---
|
# --- Login ---
|
||||||
@app.post("/api/auth/login", response_model=TokenResponse)
|
@app.post("/api/auth/login", response_model=TokenResponse)
|
||||||
async def login(
|
async def login(
|
||||||
data: LoginRequest,
|
data: LoginRequest,
|
||||||
|
request: Request,
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
username = data.username.strip()
|
||||||
|
|
||||||
|
# Alte Login-Versuche purgen (LRU-Style, einmal pro Anfrage)
|
||||||
|
await db.execute(
|
||||||
|
f"DELETE FROM portal_login_attempts WHERE ts < datetime('now', '-{PURGE_AFTER_HOURS} hours')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Brute-Force-Check: Anzahl Fehlversuche fuer (ip, username) im Zeitfenster
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""SELECT COUNT(*) AS cnt FROM portal_login_attempts
|
||||||
|
WHERE ip = ? AND username = ? AND success = 0
|
||||||
|
AND ts > datetime('now', '-{BLOCK_WINDOW_MINUTES} minutes')""",
|
||||||
|
(ip, username),
|
||||||
|
)
|
||||||
|
failed_count = (await cursor.fetchone())["cnt"]
|
||||||
|
|
||||||
|
if failed_count >= MAX_FAILED_ATTEMPTS:
|
||||||
|
await log_action(
|
||||||
|
db, admin=None, ip=ip, action="login_blocked",
|
||||||
|
resource_type="auth",
|
||||||
|
after={"username": username, "failed_attempts": failed_count},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"Zu viele Fehlversuche. Bitte {BLOCK_WINDOW_MINUTES} Minuten warten.",
|
||||||
|
headers={"Retry-After": str(BLOCK_WINDOW_MINUTES * 60)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auth-Pruefung
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id, username, password_hash FROM portal_admins WHERE username = ?",
|
"SELECT id, username, password_hash FROM portal_admins WHERE username = ?",
|
||||||
(data.username,),
|
(username,),
|
||||||
)
|
)
|
||||||
admin = await cursor.fetchone()
|
admin = await cursor.fetchone()
|
||||||
if not admin or not verify_password(data.password, admin["password_hash"]):
|
auth_ok = bool(admin and verify_password(data.password, admin["password_hash"]))
|
||||||
|
|
||||||
|
# Versuch in Tabelle eintragen (fuer Brute-Force-Tracking)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO portal_login_attempts (ip, username, success) VALUES (?, ?, ?)",
|
||||||
|
(ip, username, 1 if auth_ok else 0),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if not auth_ok:
|
||||||
|
admin_dict = (
|
||||||
|
{"id": admin["id"], "username": admin["username"]} if admin else None
|
||||||
|
)
|
||||||
|
await log_action(
|
||||||
|
db, admin=admin_dict, ip=ip, action="login_failed",
|
||||||
|
resource_type="auth",
|
||||||
|
after={"username": username},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Ungueltige Zugangsdaten",
|
detail="Ungueltige Zugangsdaten",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Erfolg
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
admin={"id": admin["id"], "username": admin["username"]},
|
||||||
|
ip=ip,
|
||||||
|
action="login_success",
|
||||||
|
resource_type="auth",
|
||||||
|
)
|
||||||
|
|
||||||
token = create_token(admin["id"], admin["username"])
|
token = create_token(admin["id"], admin["username"])
|
||||||
return TokenResponse(access_token=token, username=admin["username"])
|
return TokenResponse(access_token=token, username=admin["username"])
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class LicenseCreate(BaseModel):
|
|||||||
credits_total: Optional[int] = None
|
credits_total: Optional[int] = None
|
||||||
cost_per_credit: Optional[float] = None
|
cost_per_credit: Optional[float] = None
|
||||||
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
|
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
|
||||||
|
unlimited_budget: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LicenseResponse(BaseModel):
|
class LicenseResponse(BaseModel):
|
||||||
@@ -62,6 +63,7 @@ class LicenseResponse(BaseModel):
|
|||||||
credits_used: Optional[float] = None
|
credits_used: Optional[float] = None
|
||||||
cost_per_credit: Optional[float] = None
|
cost_per_credit: Optional[float] = None
|
||||||
budget_warning_percent: Optional[int] = None
|
budget_warning_percent: Optional[int] = None
|
||||||
|
unlimited_budget: bool = False
|
||||||
created_at: str
|
created_at: str
|
||||||
globe_access: bool = False
|
globe_access: bool = False
|
||||||
network_access: bool = False
|
network_access: bool = False
|
||||||
|
|||||||
115
src/routers/audit.py
Normale Datei
115
src/routers/audit.py
Normale Datei
@@ -0,0 +1,115 @@
|
|||||||
|
"""Audit-Log Read-only Endpoint."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from auth import get_current_admin
|
||||||
|
from database import db_dependency
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger("verwaltung.audit_router")
|
||||||
|
router = APIRouter(prefix="/api/audit-log", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(s: Optional[str]):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except Exception:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_audit(
|
||||||
|
action: Optional[str] = None,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
admin_id: Optional[int] = None,
|
||||||
|
from_ts: Optional[str] = None,
|
||||||
|
to_ts: Optional[str] = None,
|
||||||
|
limit: int = 200,
|
||||||
|
offset: int = 0,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Audit-Log-Eintraege auflisten mit Filter + Pagination."""
|
||||||
|
if limit < 1 or limit > 1000:
|
||||||
|
limit = 200
|
||||||
|
if offset < 0:
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
if action:
|
||||||
|
where.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
if resource_type:
|
||||||
|
where.append("resource_type = ?")
|
||||||
|
params.append(resource_type)
|
||||||
|
if admin_id is not None:
|
||||||
|
where.append("admin_id = ?")
|
||||||
|
params.append(admin_id)
|
||||||
|
if from_ts:
|
||||||
|
where.append("ts >= ?")
|
||||||
|
params.append(from_ts)
|
||||||
|
if to_ts:
|
||||||
|
where.append("ts <= ?")
|
||||||
|
params.append(to_ts)
|
||||||
|
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
|
||||||
|
# Total count fuer Pagination
|
||||||
|
cursor = await db.execute(f"SELECT COUNT(*) AS cnt FROM portal_audit_log {where_sql}", params)
|
||||||
|
total = (await cursor.fetchone())["cnt"]
|
||||||
|
|
||||||
|
# Daten
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""SELECT id, ts, admin_id, admin_username, ip, action,
|
||||||
|
resource_type, resource_id, before_json, after_json
|
||||||
|
FROM portal_audit_log {where_sql}
|
||||||
|
ORDER BY ts DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
params + [limit, offset],
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"ts": r["ts"],
|
||||||
|
"admin_id": r["admin_id"],
|
||||||
|
"admin_username": r["admin_username"],
|
||||||
|
"ip": r["ip"],
|
||||||
|
"action": r["action"],
|
||||||
|
"resource_type": r["resource_type"],
|
||||||
|
"resource_id": r["resource_id"],
|
||||||
|
"before": _parse_json(r["before_json"]),
|
||||||
|
"after": _parse_json(r["after_json"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/distinct")
|
||||||
|
async def get_distinct_values(
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Distinct-Werte fuer Filter-Dropdowns."""
|
||||||
|
cursor = await db.execute("SELECT DISTINCT action FROM portal_audit_log ORDER BY action")
|
||||||
|
actions = [r["action"] for r in await cursor.fetchall()]
|
||||||
|
cursor = await db.execute("SELECT DISTINCT resource_type FROM portal_audit_log WHERE resource_type IS NOT NULL ORDER BY resource_type")
|
||||||
|
resource_types = [r["resource_type"] for r in await cursor.fetchall()]
|
||||||
|
cursor = await db.execute("SELECT DISTINCT admin_id, admin_username FROM portal_audit_log WHERE admin_id IS NOT NULL ORDER BY admin_username")
|
||||||
|
admins = [{"id": r["admin_id"], "username": r["admin_username"]} for r in await cursor.fetchall()]
|
||||||
|
return {
|
||||||
|
"actions": actions,
|
||||||
|
"resource_types": resource_types,
|
||||||
|
"admins": admins,
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Lizenz-CRUD."""
|
"""Lizenz-CRUD."""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from models import LicenseCreate, LicenseResponse
|
from models import LicenseCreate, LicenseResponse
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
|
from audit import log_action, get_client_ip, row_to_dict
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
|
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
|
||||||
@@ -28,21 +29,35 @@ async def list_licenses(
|
|||||||
@router.post("", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_license(
|
async def create_license(
|
||||||
data: LicenseCreate,
|
data: LicenseCreate,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
# Org pruefen
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id FROM organizations WHERE id = ?", (data.organization_id,)
|
"SELECT id FROM organizations WHERE id = ?", (data.organization_id,)
|
||||||
)
|
)
|
||||||
if not await cursor.fetchone():
|
if not await cursor.fetchone():
|
||||||
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
||||||
|
|
||||||
# Bestehende aktive Lizenz widerrufen
|
# Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
|
||||||
|
(data.organization_id,),
|
||||||
|
)
|
||||||
|
revoked_lics = [dict(r) for r in await cursor.fetchall()]
|
||||||
|
if revoked_lics:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
|
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
|
||||||
(data.organization_id,),
|
(data.organization_id,),
|
||||||
)
|
)
|
||||||
|
for old_lic in revoked_lics:
|
||||||
|
new_lic = dict(old_lic)
|
||||||
|
new_lic["status"] = "revoked"
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="license", resource_id=old_lic["id"],
|
||||||
|
before=old_lic, after=new_lic,
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
valid_from = now.isoformat()
|
valid_from = now.isoformat()
|
||||||
@@ -57,49 +72,74 @@ async def create_license(
|
|||||||
elif data.license_type == "annual":
|
elif data.license_type == "annual":
|
||||||
valid_until = (now + timedelta(days=365)).isoformat()
|
valid_until = (now + timedelta(days=365)).isoformat()
|
||||||
|
|
||||||
|
# Bei unlimited_budget: Credits/Cost/Budget ignorieren
|
||||||
|
if data.unlimited_budget:
|
||||||
|
token_budget_usd = None
|
||||||
|
credits_total = None
|
||||||
|
cost_per_credit = None
|
||||||
|
else:
|
||||||
|
token_budget_usd = data.token_budget_usd
|
||||||
|
credits_total = data.credits_total
|
||||||
|
cost_per_credit = data.cost_per_credit
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
|
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
|
||||||
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent)
|
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget)
|
||||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
|
||||||
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
|
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
|
||||||
data.token_budget_usd, data.credits_total, data.cost_per_credit, data.budget_warning_percent),
|
token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent,
|
||||||
|
1 if data.unlimited_budget else 0),
|
||||||
)
|
)
|
||||||
|
lic_id = cursor.lastrowid
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (cursor.lastrowid,))
|
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
|
||||||
return dict(await cursor.fetchone())
|
new_lic = dict(await cursor.fetchone())
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="create", resource_type="license", resource_id=lic_id,
|
||||||
|
after=new_lic,
|
||||||
|
)
|
||||||
|
return new_lic
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{license_id}/revoke")
|
@router.put("/{license_id}/revoke")
|
||||||
async def revoke_license(
|
async def revoke_license(
|
||||||
license_id: int,
|
license_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,))
|
before = await row_to_dict(db, "licenses", license_id)
|
||||||
lic = await cursor.fetchone()
|
if not before:
|
||||||
if not lic:
|
|
||||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||||
|
|
||||||
await db.execute("UPDATE licenses SET status = 'revoked' WHERE id = ?", (license_id,))
|
await db.execute("UPDATE licenses SET status = 'revoked' WHERE id = ?", (license_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
after = await row_to_dict(db, "licenses", license_id)
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="license", resource_id=license_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{license_id}/extend")
|
@router.put("/{license_id}/extend")
|
||||||
async def extend_license(
|
async def extend_license(
|
||||||
license_id: int,
|
license_id: int,
|
||||||
|
request: Request,
|
||||||
days: int = 365,
|
days: int = 365,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,))
|
before = await row_to_dict(db, "licenses", license_id)
|
||||||
lic = await cursor.fetchone()
|
if not before:
|
||||||
if not lic:
|
|
||||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||||
|
|
||||||
if lic["valid_until"]:
|
if before.get("valid_until"):
|
||||||
base = datetime.fromisoformat(lic["valid_until"])
|
base = datetime.fromisoformat(before["valid_until"])
|
||||||
else:
|
else:
|
||||||
base = datetime.now(timezone.utc)
|
base = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -109,6 +149,13 @@ async def extend_license(
|
|||||||
(new_until, license_id),
|
(new_until, license_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
after = await row_to_dict(db, "licenses", license_id)
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="license", resource_id=license_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
return {"ok": True, "valid_until": new_until}
|
return {"ok": True, "valid_until": new_until}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Organisations-CRUD."""
|
"""Organisations-CRUD."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from models import OrgCreate, OrgUpdate, OrgResponse
|
from models import OrgCreate, OrgUpdate, OrgResponse
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
|
from audit import log_action, get_client_ip, row_to_dict
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/orgs", tags=["organizations"])
|
router = APIRouter(prefix="/api/orgs", tags=["organizations"])
|
||||||
@@ -40,10 +41,10 @@ async def list_organizations(
|
|||||||
@router.post("", response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_organization(
|
async def create_organization(
|
||||||
data: OrgCreate,
|
data: OrgCreate,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
# Slug-Duplikat pruefen
|
|
||||||
cursor = await db.execute("SELECT id FROM organizations WHERE slug = ?", (data.slug,))
|
cursor = await db.execute("SELECT id FROM organizations WHERE slug = ?", (data.slug,))
|
||||||
if await cursor.fetchone():
|
if await cursor.fetchone():
|
||||||
raise HTTPException(status_code=400, detail="Slug bereits vergeben")
|
raise HTTPException(status_code=400, detail="Slug bereits vergeben")
|
||||||
@@ -53,10 +54,17 @@ async def create_organization(
|
|||||||
"INSERT INTO organizations (name, slug, is_active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)",
|
"INSERT INTO organizations (name, slug, is_active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)",
|
||||||
(data.name, data.slug, now, now),
|
(data.name, data.slug, now, now),
|
||||||
)
|
)
|
||||||
|
org_id = cursor.lastrowid
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (cursor.lastrowid,))
|
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
|
||||||
return await _enrich_org(db, await cursor.fetchone())
|
new_row_obj = await cursor.fetchone()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="create", resource_type="organization", resource_id=org_id,
|
||||||
|
after=dict(new_row_obj),
|
||||||
|
)
|
||||||
|
return await _enrich_org(db, new_row_obj)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{org_id}", response_model=OrgResponse)
|
@router.get("/{org_id}", response_model=OrgResponse)
|
||||||
@@ -76,12 +84,12 @@ async def get_organization(
|
|||||||
async def update_organization(
|
async def update_organization(
|
||||||
org_id: int,
|
org_id: int,
|
||||||
data: OrgUpdate,
|
data: OrgUpdate,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
|
before = await row_to_dict(db, "organizations", org_id)
|
||||||
row = await cursor.fetchone()
|
if not before:
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
@@ -97,6 +105,13 @@ async def update_organization(
|
|||||||
await db.execute(f"UPDATE organizations SET {set_clause} WHERE id = ?", values)
|
await db.execute(f"UPDATE organizations SET {set_clause} WHERE id = ?", values)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
after = await row_to_dict(db, "organizations", org_id)
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="organization", resource_id=org_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
|
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
|
||||||
return await _enrich_org(db, await cursor.fetchone())
|
return await _enrich_org(db, await cursor.fetchone())
|
||||||
|
|
||||||
@@ -104,13 +119,18 @@ async def update_organization(
|
|||||||
@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_organization(
|
async def delete_organization(
|
||||||
org_id: int,
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
|
before = await row_to_dict(db, "organizations", org_id)
|
||||||
if not await cursor.fetchone():
|
if not before:
|
||||||
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
||||||
|
|
||||||
# Kaskadierendes Loeschen
|
|
||||||
await db.execute("DELETE FROM organizations WHERE id = ?", (org_id,))
|
await db.execute("DELETE FROM organizations WHERE id = ?", (org_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="delete", resource_type="organization", resource_id=org_id,
|
||||||
|
before=before,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import logging
|
|||||||
# Monitor-Source-Rules verfügbar machen
|
# Monitor-Source-Rules verfügbar machen
|
||||||
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
|
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
|
from audit import log_action, get_client_ip, row_to_dict
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join('/home/claude-dev/AegisSight-Monitor/src'))
|
sys.path.insert(0, os.path.join('/home/claude-dev/AegisSight-Monitor/src'))
|
||||||
@@ -65,6 +66,7 @@ async def list_global_sources(
|
|||||||
@router.post("/global", status_code=201)
|
@router.post("/global", status_code=201)
|
||||||
async def create_global_source(
|
async def create_global_source(
|
||||||
data: GlobalSourceCreate,
|
data: GlobalSourceCreate,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -86,16 +88,24 @@ async def create_global_source(
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
||||||
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
|
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
|
||||||
)
|
)
|
||||||
|
src_id = cursor.lastrowid
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (src_id,))
|
||||||
return dict(await cursor.fetchone())
|
new_src = dict(await cursor.fetchone())
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="create", resource_type="source", resource_id=src_id,
|
||||||
|
after=new_src,
|
||||||
|
)
|
||||||
|
return new_src
|
||||||
|
|
||||||
|
|
||||||
@router.put("/global/{source_id}")
|
@router.put("/global/{source_id}")
|
||||||
async def update_global_source(
|
async def update_global_source(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
data: GlobalSourceUpdate,
|
data: GlobalSourceUpdate,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -106,13 +116,14 @@ async def update_global_source(
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
||||||
|
before = dict(row)
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
for field, value in data.model_dump(exclude_none=True).items():
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
updates[field] = value
|
updates[field] = value
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return dict(row)
|
return before
|
||||||
|
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
values = list(updates.values()) + [source_id]
|
values = list(updates.values()) + [source_id]
|
||||||
@@ -120,24 +131,38 @@ async def update_global_source(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
return dict(await cursor.fetchone())
|
after = dict(await cursor.fetchone())
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="source", resource_id=source_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
|
return after
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/global/{source_id}", status_code=204)
|
@router.delete("/global/{source_id}", status_code=204)
|
||||||
async def delete_global_source(
|
async def delete_global_source(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
"""Grundquelle loeschen."""
|
"""Grundquelle loeschen."""
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
|
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
|
||||||
)
|
)
|
||||||
if not await cursor.fetchone():
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
||||||
|
before = dict(row)
|
||||||
|
|
||||||
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="delete", resource_type="source", resource_id=source_id,
|
||||||
|
before=before,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tenant")
|
@router.get("/tenant")
|
||||||
@@ -159,6 +184,7 @@ async def list_tenant_sources(
|
|||||||
@router.post("/tenant/{source_id}/promote")
|
@router.post("/tenant/{source_id}/promote")
|
||||||
async def promote_to_global(
|
async def promote_to_global(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -169,6 +195,7 @@ async def promote_to_global(
|
|||||||
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
||||||
if row["tenant_id"] is None:
|
if row["tenant_id"] is None:
|
||||||
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
|
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
|
||||||
|
before = dict(row)
|
||||||
|
|
||||||
if row["url"]:
|
if row["url"]:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -185,7 +212,13 @@ async def promote_to_global(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
return dict(await cursor.fetchone())
|
after = dict(await cursor.fetchone())
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="source", resource_id=source_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
|
return after
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -267,6 +300,7 @@ async def discover_source_endpoint(
|
|||||||
@router.post("/discover/add")
|
@router.post("/discover/add")
|
||||||
async def add_discovered_sources(
|
async def add_discovered_sources(
|
||||||
feeds: list[dict],
|
feeds: list[dict],
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -282,6 +316,7 @@ async def add_discovered_sources(
|
|||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
added_ids = []
|
||||||
for feed in feeds:
|
for feed in feeds:
|
||||||
if not feed.get("url"):
|
if not feed.get("url"):
|
||||||
continue
|
continue
|
||||||
@@ -290,11 +325,12 @@ async def add_discovered_sources(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
domain = feed.get("domain", "")
|
domain = feed.get("domain", "")
|
||||||
await db.execute(
|
cur = await db.execute(
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||||
VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
|
VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
|
||||||
(feed["name"], feed["url"], domain, feed.get("category", "sonstige")),
|
(feed["name"], feed["url"], domain, feed.get("category", "sonstige")),
|
||||||
)
|
)
|
||||||
|
added_ids.append(cur.lastrowid)
|
||||||
existing_urls.add(feed["url"])
|
existing_urls.add(feed["url"])
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
@@ -315,6 +351,13 @@ async def add_discovered_sources(
|
|||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
if added_ids:
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="create", resource_type="source",
|
||||||
|
after={"discovered_add": {"count": added, "ids": added_ids,
|
||||||
|
"domain": feeds[0].get("domain") if feeds else None}},
|
||||||
|
)
|
||||||
return {"added": added, "skipped": skipped}
|
return {"added": added, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
@@ -394,6 +437,7 @@ class SuggestionAction(BaseModel):
|
|||||||
async def update_suggestion(
|
async def update_suggestion(
|
||||||
suggestion_id: int,
|
suggestion_id: int,
|
||||||
action: SuggestionAction,
|
action: SuggestionAction,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -478,6 +522,14 @@ async def update_suggestion(
|
|||||||
(new_status, suggestion_id),
|
(new_status, suggestion_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="source",
|
||||||
|
resource_id=suggestion.get("source_id"),
|
||||||
|
before={"suggestion_id": suggestion_id, "status": "pending"},
|
||||||
|
after={"suggestion_id": suggestion_id, "status": new_status,
|
||||||
|
"result_action": result_action},
|
||||||
|
)
|
||||||
return {"status": new_status, "action": result_action}
|
return {"status": new_status, "action": result_action}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Token-Usage & Budget-Verwaltung."""
|
"""Token-Usage & Budget-Verwaltung."""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from audit import log_action, get_client_ip, row_to_dict
|
||||||
|
|
||||||
logger = logging.getLogger("verwaltung.token_usage")
|
logger = logging.getLogger("verwaltung.token_usage")
|
||||||
router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"])
|
router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"])
|
||||||
@@ -18,7 +19,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
|||||||
SELECT
|
SELECT
|
||||||
o.id, o.name, o.slug,
|
o.id, o.name, o.slug,
|
||||||
l.credits_total, l.credits_used, l.cost_per_credit,
|
l.credits_total, l.credits_used, l.cost_per_credit,
|
||||||
l.token_budget_usd, l.budget_warning_percent,
|
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
|
||||||
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
|
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
|
||||||
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
|
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
|
||||||
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
|
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
|
||||||
@@ -35,11 +36,12 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
credits_total = row["credits_total"] or 0
|
credits_total = row["credits_total"] or 0
|
||||||
credits_used = row["credits_used"] or 0
|
credits_used = row["credits_used"] or 0
|
||||||
credits_remaining = max(0, int(credits_total - credits_used)) if credits_total else None
|
unlimited = bool(row["unlimited_budget"])
|
||||||
percent_used = round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None
|
credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None)
|
||||||
|
percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None)
|
||||||
budget_usd = row["token_budget_usd"]
|
budget_usd = row["token_budget_usd"]
|
||||||
cost = row["total_cost"]
|
cost = row["total_cost"]
|
||||||
budget_percent = round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None
|
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"org_id": row["id"],
|
"org_id": row["id"],
|
||||||
@@ -53,6 +55,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
|||||||
"total_cost_usd": round(cost, 2),
|
"total_cost_usd": round(cost, 2),
|
||||||
"budget_percent_used": budget_percent,
|
"budget_percent_used": budget_percent,
|
||||||
"budget_warning_percent": row["budget_warning_percent"] or 80,
|
"budget_warning_percent": row["budget_warning_percent"] or 80,
|
||||||
|
"unlimited_budget": unlimited,
|
||||||
"total_input_tokens": row["total_input_tokens"],
|
"total_input_tokens": row["total_input_tokens"],
|
||||||
"total_output_tokens": row["total_output_tokens"],
|
"total_output_tokens": row["total_output_tokens"],
|
||||||
"total_api_calls": row["total_api_calls"],
|
"total_api_calls": row["total_api_calls"],
|
||||||
@@ -100,7 +103,6 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|||||||
"SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?",
|
"SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?",
|
||||||
(org_id, year_month))
|
(org_id, year_month))
|
||||||
usage_rows = await cursor.fetchall()
|
usage_rows = await cursor.fetchall()
|
||||||
# Summe ueber alle Sources
|
|
||||||
usage = {
|
usage = {
|
||||||
"input_tokens": sum(r["input_tokens"] for r in usage_rows),
|
"input_tokens": sum(r["input_tokens"] for r in usage_rows),
|
||||||
"output_tokens": sum(r["output_tokens"] for r in usage_rows),
|
"output_tokens": sum(r["output_tokens"] for r in usage_rows),
|
||||||
@@ -108,7 +110,6 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|||||||
"api_calls": sum(r["api_calls"] for r in usage_rows),
|
"api_calls": sum(r["api_calls"] for r in usage_rows),
|
||||||
"refresh_count": sum(r["refresh_count"] for r in usage_rows),
|
"refresh_count": sum(r["refresh_count"] for r in usage_rows),
|
||||||
}
|
}
|
||||||
# Per-Source Aufschluesselung
|
|
||||||
usage_by_source = {}
|
usage_by_source = {}
|
||||||
for r in usage_rows:
|
for r in usage_rows:
|
||||||
src = r["source"] if "source" in r.keys() else "monitor"
|
src = r["source"] if "source" in r.keys() else "monitor"
|
||||||
@@ -121,10 +122,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
(org_id,))
|
(org_id,))
|
||||||
lic = await cursor.fetchone()
|
lic = await cursor.fetchone()
|
||||||
|
|
||||||
|
unlimited = bool(lic["unlimited_budget"]) if lic else False
|
||||||
credits_total = lic["credits_total"] if lic else None
|
credits_total = lic["credits_total"] if lic else None
|
||||||
credits_used = lic["credits_used"] if lic else 0
|
credits_used = lic["credits_used"] if lic else 0
|
||||||
|
|
||||||
@@ -139,10 +141,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|||||||
},
|
},
|
||||||
"usage_by_source": usage_by_source,
|
"usage_by_source": usage_by_source,
|
||||||
"budget": {
|
"budget": {
|
||||||
|
"unlimited_budget": unlimited,
|
||||||
"credits_total": credits_total,
|
"credits_total": credits_total,
|
||||||
"credits_used": round(credits_used, 1) if credits_used else 0,
|
"credits_used": round(credits_used, 1) if credits_used else 0,
|
||||||
"credits_remaining": max(0, int(credits_total - credits_used)) if credits_total else None,
|
"credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None),
|
||||||
"credits_percent_used": round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None,
|
"credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None),
|
||||||
"token_budget_usd": lic["token_budget_usd"] if lic else None,
|
"token_budget_usd": lic["token_budget_usd"] if lic else None,
|
||||||
"cost_per_credit": lic["cost_per_credit"] if lic else None,
|
"cost_per_credit": lic["cost_per_credit"] if lic else None,
|
||||||
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
|
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
|
||||||
@@ -153,12 +156,12 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/budget/{license_id}")
|
@router.put("/budget/{license_id}")
|
||||||
async def update_budget(license_id: int, data: dict, admin=Depends(get_current_admin)):
|
async def update_budget(license_id: int, data: dict, request: Request, admin=Depends(get_current_admin)):
|
||||||
"""Budget einer Lizenz setzen/ändern."""
|
"""Budget einer Lizenz setzen/aendern."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute("SELECT id FROM licenses WHERE id = ?", (license_id,))
|
before = await row_to_dict(db, "licenses", license_id)
|
||||||
if not await cursor.fetchone():
|
if not before:
|
||||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||||
|
|
||||||
fields = []
|
fields = []
|
||||||
@@ -172,6 +175,10 @@ async def update_budget(license_id: int, data: dict, admin=Depends(get_current_a
|
|||||||
fields.append("credits_used = ?")
|
fields.append("credits_used = ?")
|
||||||
values.append(data["credits_used"])
|
values.append(data["credits_used"])
|
||||||
|
|
||||||
|
if "unlimited_budget" in data:
|
||||||
|
fields.append("unlimited_budget = ?")
|
||||||
|
values.append(1 if data["unlimited_budget"] else 0)
|
||||||
|
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||||
|
|
||||||
@@ -179,7 +186,22 @@ async def update_budget(license_id: int, data: dict, admin=Depends(get_current_a
|
|||||||
await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values)
|
await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(f"Budget für Lizenz {license_id} aktualisiert: {data}")
|
after = await row_to_dict(db, "licenses", license_id)
|
||||||
return {"ok": True}
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="license", resource_id=license_id,
|
||||||
|
before=before, after=after,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Konsistenz-Hinweis
|
||||||
|
warning = None
|
||||||
|
if after and not after.get("unlimited_budget"):
|
||||||
|
cpc = after.get("cost_per_credit")
|
||||||
|
ct = after.get("credits_total")
|
||||||
|
if (not cpc or cpc == 0) and (not ct or ct == 0):
|
||||||
|
warning = "Achtung: cost_per_credit und credits_total sind leer/0 - Budget wird nicht getrackt. Bitte 'Unbegrenzt' aktivieren oder gueltige Werte eintragen."
|
||||||
|
|
||||||
|
logger.info(f"Budget fuer Lizenz {license_id} aktualisiert: {data}")
|
||||||
|
return {"ok": True, "warning": warning}
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from models import UserCreate, UserResponse
|
from models import UserCreate, UserResponse
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
|
from audit import log_action, get_client_ip, row_to_dict
|
||||||
from config import MAGIC_LINK_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
|
from config import MAGIC_LINK_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ async def list_users(
|
|||||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
data: UserCreate,
|
data: UserCreate,
|
||||||
|
request: Request,
|
||||||
org_id: int = None,
|
org_id: int = None,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
@@ -40,13 +42,11 @@ async def create_user(
|
|||||||
|
|
||||||
email = data.email.lower().strip()
|
email = data.email.lower().strip()
|
||||||
|
|
||||||
# Org pruefen
|
|
||||||
cursor = await db.execute("SELECT id, name FROM organizations WHERE id = ?", (org_id,))
|
cursor = await db.execute("SELECT id, name FROM organizations WHERE id = ?", (org_id,))
|
||||||
org = await cursor.fetchone()
|
org = await cursor.fetchone()
|
||||||
if not org:
|
if not org:
|
||||||
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
|
||||||
|
|
||||||
# Nutzer-Limit pruefen
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT max_users FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
"SELECT max_users FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
||||||
(org_id,),
|
(org_id,),
|
||||||
@@ -61,7 +61,6 @@ async def create_user(
|
|||||||
if current >= lic["max_users"]:
|
if current >= lic["max_users"]:
|
||||||
raise HTTPException(status_code=400, detail=f"Nutzer-Limit erreicht ({current}/{lic['max_users']})")
|
raise HTTPException(status_code=400, detail=f"Nutzer-Limit erreicht ({current}/{lic['max_users']})")
|
||||||
|
|
||||||
# E-Mail-Duplikat
|
|
||||||
cursor = await db.execute("SELECT id FROM users WHERE LOWER(email) = ?", (email,))
|
cursor = await db.execute("SELECT id FROM users WHERE LOWER(email) = ?", (email,))
|
||||||
if await cursor.fetchone():
|
if await cursor.fetchone():
|
||||||
raise HTTPException(status_code=400, detail="E-Mail bereits vergeben")
|
raise HTTPException(status_code=400, detail="E-Mail bereits vergeben")
|
||||||
@@ -75,7 +74,6 @@ async def create_user(
|
|||||||
)
|
)
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
|
|
||||||
# Magic Link fuer Einladung erstellen
|
|
||||||
token = secrets.token_urlsafe(48)
|
token = secrets.token_urlsafe(48)
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
|
expires_at = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
|
||||||
@@ -87,7 +85,6 @@ async def create_user(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Einladungs-E-Mail senden
|
|
||||||
try:
|
try:
|
||||||
from email_utils.sender import send_email
|
from email_utils.sender import send_email
|
||||||
from email_utils.templates import invite_email
|
from email_utils.templates import invite_email
|
||||||
@@ -95,47 +92,61 @@ async def create_user(
|
|||||||
subject, html = invite_email(username, org["name"], code, link)
|
subject, html = invite_email(username, org["name"], code, link)
|
||||||
await send_email(email, subject, html)
|
await send_email(email, subject, html)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # E-Mail-Fehler nicht fatal
|
pass
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
return dict(await cursor.fetchone())
|
new_user = dict(await cursor.fetchone())
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="create", resource_type="user", resource_id=user_id,
|
||||||
|
after={k: v for k, v in new_user.items() if k != "password_hash"},
|
||||||
|
)
|
||||||
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
async def _toggle_field(db, request, admin, user_id: int, field: str, value: int):
|
||||||
|
"""Hilfsfunktion: ein Feld aktualisieren + Audit."""
|
||||||
|
before = await row_to_dict(db, "users", user_id)
|
||||||
|
if not before:
|
||||||
|
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
||||||
|
await db.execute(f"UPDATE users SET {field} = ? WHERE id = ?", (value, user_id))
|
||||||
|
await db.commit()
|
||||||
|
after = await row_to_dict(db, "users", user_id)
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="user", resource_id=user_id,
|
||||||
|
before={field: before.get(field)},
|
||||||
|
after={field: after.get(field)},
|
||||||
|
)
|
||||||
|
return after
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/deactivate")
|
@router.put("/{user_id}/deactivate")
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
await _toggle_field(db, request, admin, user_id, "is_active", 0)
|
||||||
if not await cursor.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
|
||||||
|
|
||||||
await db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user_id,))
|
|
||||||
await db.commit()
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/activate")
|
@router.put("/{user_id}/activate")
|
||||||
async def activate_user(
|
async def activate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
await _toggle_field(db, request, admin, user_id, "is_active", 1)
|
||||||
if not await cursor.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
|
||||||
|
|
||||||
await db.execute("UPDATE users SET is_active = 1 WHERE id = ?", (user_id,))
|
|
||||||
await db.commit()
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/globe-access")
|
@router.put("/{user_id}/globe-access")
|
||||||
async def toggle_globe_access(
|
async def toggle_globe_access(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -143,48 +154,15 @@ async def toggle_globe_access(
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
||||||
|
|
||||||
new_val = 0 if row[1] else 1
|
new_val = 0 if row[1] else 1
|
||||||
await db.execute("UPDATE users SET globe_access = ? WHERE id = ?", (new_val, user_id))
|
await _toggle_field(db, request, admin, user_id, "globe_access", new_val)
|
||||||
await db.commit()
|
|
||||||
return {"ok": True, "globe_access": bool(new_val)}
|
return {"ok": True, "globe_access": bool(new_val)}
|
||||||
|
|
||||||
@router.put("/{user_id}/role")
|
|
||||||
async def change_role(
|
|
||||||
user_id: int,
|
|
||||||
role: str = "member",
|
|
||||||
admin: dict = Depends(get_current_admin),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
if role not in ("org_admin", "member"):
|
|
||||||
raise HTTPException(status_code=400, detail="Ungueltige Rolle")
|
|
||||||
|
|
||||||
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
|
||||||
if not await cursor.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
|
||||||
|
|
||||||
await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
|
||||||
await db.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
admin: dict = Depends(get_current_admin),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
|
||||||
if not await cursor.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
|
||||||
|
|
||||||
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/network-access")
|
@router.put("/{user_id}/network-access")
|
||||||
async def toggle_network_access(
|
async def toggle_network_access(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -192,8 +170,39 @@ async def toggle_network_access(
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
||||||
|
|
||||||
new_val = 0 if row[1] else 1
|
new_val = 0 if row[1] else 1
|
||||||
await db.execute("UPDATE users SET network_access = ? WHERE id = ?", (new_val, user_id))
|
await _toggle_field(db, request, admin, user_id, "network_access", new_val)
|
||||||
await db.commit()
|
|
||||||
return {"ok": True, "network_access": bool(new_val)}
|
return {"ok": True, "network_access": bool(new_val)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/role")
|
||||||
|
async def change_role(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
role: str = "member",
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
if role not in ("org_admin", "member"):
|
||||||
|
raise HTTPException(status_code=400, detail="Ungueltige Rolle")
|
||||||
|
await _toggle_field(db, request, admin, user_id, "role", role)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
before = await row_to_dict(db, "users", user_id)
|
||||||
|
if not before:
|
||||||
|
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden")
|
||||||
|
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
await db.commit()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="delete", resource_type="user", resource_id=user_id,
|
||||||
|
before={k: v for k, v in before.items() if k != "password_hash"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -777,3 +777,16 @@ tr:hover td {
|
|||||||
.token-budget-bar.warning { background: #e67e22; }
|
.token-budget-bar.warning { background: #e67e22; }
|
||||||
.token-budget-bar.critical { background: #e74c3c; }
|
.token-budget-bar.critical { background: #e74c3c; }
|
||||||
@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } }
|
@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
/* === Audit-Log === */
|
||||||
|
.audit-row { cursor: pointer; }
|
||||||
|
.audit-row:hover { background: var(--bg-hover, rgba(255,255,255,0.03)); }
|
||||||
|
.audit-detail-row td { background: var(--bg-tertiary, #0f172a); padding: 12px 16px; }
|
||||||
|
.audit-diff { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.audit-diff th, .audit-diff td { padding: 4px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||||
|
.audit-diff th { font-weight: 600; color: var(--text-secondary); }
|
||||||
|
.audit-diff .diff-key { font-weight: 600; color: var(--text-secondary); width: 180px; word-break: break-word; }
|
||||||
|
.audit-diff .diff-old { color: #e74c3c; word-break: break-word; }
|
||||||
|
.audit-diff .diff-new { color: #2ecc71; word-break: break-word; }
|
||||||
|
.token-budget-bar.over-limit { background: repeating-linear-gradient(45deg, #c0392b, #c0392b 6px, #962d22 6px, #962d22 12px); }
|
||||||
|
input[type="date"].filter-select { padding: 6px 10px; }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<button class="nav-tab" data-section="orgs">Organisationen</button>
|
<button class="nav-tab" data-section="orgs">Organisationen</button>
|
||||||
<button class="nav-tab" data-section="licenses">Lizenzen</button>
|
<button class="nav-tab" data-section="licenses">Lizenzen</button>
|
||||||
<button class="nav-tab" data-section="sources">Quellen</button>
|
<button class="nav-tab" data-section="sources">Quellen</button>
|
||||||
|
<button class="nav-tab" data-section="audit">Audit-Log</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Dashboard Section -->
|
<!-- Dashboard Section -->
|
||||||
@@ -224,6 +225,12 @@
|
|||||||
<div class="card" style="margin-top:12px;">
|
<div class="card" style="margin-top:12px;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary,#0f172a); border-radius:6px; border:1px solid var(--border);">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
|
||||||
|
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
|
||||||
|
<span><strong>Unbegrenztes Budget</strong> — Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editCreditsTotal">Credits-Kontingent</label>
|
<label for="editCreditsTotal">Credits-Kontingent</label>
|
||||||
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
|
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
|
||||||
@@ -392,6 +399,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit-Log Section -->
|
||||||
|
<div class="section" id="sec-audit">
|
||||||
|
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<select class="filter-select" id="auditFilterAction">
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="auditFilterResource">
|
||||||
|
<option value="">Alle Ressourcen</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="auditFilterAdmin">
|
||||||
|
<option value="">Alle Admins</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)">
|
||||||
|
<input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)">
|
||||||
|
<button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button>
|
||||||
|
<span class="text-secondary" id="auditCount"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<button class="btn btn-secondary btn-small" id="auditPrevBtn" disabled>← Zurueck</button>
|
||||||
|
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:170px;">Zeitpunkt</th>
|
||||||
|
<th style="width:120px;">Admin</th>
|
||||||
|
<th style="width:140px;">IP</th>
|
||||||
|
<th style="width:140px;">Aktion</th>
|
||||||
|
<th>Ressource</th>
|
||||||
|
<th style="width:50px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="auditTable"><tr><td colspan="6" class="text-muted">Lade...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal: New Organization -->
|
<!-- Modal: New Organization -->
|
||||||
@@ -478,6 +527,12 @@
|
|||||||
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||||
|
<input type="checkbox" id="newLicUnlimited" onchange="onNewLicUnlimitedToggle()">
|
||||||
|
<span><strong>Unbegrenztes Budget</strong> — getrackt, aber kein Hard-Stop</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newLicCreditsTotal">Credits-Kontingent</label>
|
<label for="newLicCreditsTotal">Credits-Kontingent</label>
|
||||||
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
|
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
|
||||||
@@ -626,5 +681,6 @@
|
|||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
<script src="/static/js/sources.js"></script>
|
<script src="/static/js/sources.js"></script>
|
||||||
<script src="/static/js/source-health.js"></script>
|
<script src="/static/js/source-health.js"></script>
|
||||||
|
<script src="/static/js/audit.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.');
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
|
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function setupNavTabs() {
|
|||||||
document.getElementById(`sec-${section}`).classList.add("active");
|
document.getElementById(`sec-${section}`).classList.add("active");
|
||||||
|
|
||||||
if (section === "licenses") loadExpiringLicenses();
|
if (section === "licenses") loadExpiringLicenses();
|
||||||
|
if (section === "audit" && typeof loadAudit === "function") loadAudit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -489,12 +490,16 @@ function setupForms() {
|
|||||||
if (licType !== "permanent") {
|
if (licType !== "permanent") {
|
||||||
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
||||||
}
|
}
|
||||||
|
const unlimitedEl = document.getElementById('newLicUnlimited');
|
||||||
|
body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked);
|
||||||
|
if (!body.unlimited_budget) {
|
||||||
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
||||||
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
||||||
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
||||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await API.post("/api/licenses", body);
|
await API.post("/api/licenses", body);
|
||||||
closeModal("modalNewLicense");
|
closeModal("modalNewLicense");
|
||||||
@@ -599,11 +604,17 @@ async function loadOrgTokenUsage(orgId) {
|
|||||||
|
|
||||||
const budget = current.budget || {};
|
const budget = current.budget || {};
|
||||||
const usage = current.usage || {};
|
const usage = current.usage || {};
|
||||||
|
const unlimited = !!budget.unlimited_budget;
|
||||||
|
|
||||||
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
||||||
el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-');
|
el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-');
|
||||||
|
if (unlimited) {
|
||||||
|
el('tokenCreditsRemaining', '∞ Unbegrenzt');
|
||||||
|
el('tokenBudgetUsd', '—');
|
||||||
|
} else {
|
||||||
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
||||||
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
|
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
|
||||||
|
}
|
||||||
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
|
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
|
||||||
|
|
||||||
// Source-Split anzeigen
|
// Source-Split anzeigen
|
||||||
@@ -616,14 +627,29 @@ async function loadOrgTokenUsage(orgId) {
|
|||||||
|
|
||||||
const bar = document.getElementById('tokenBudgetBar');
|
const bar = document.getElementById('tokenBudgetBar');
|
||||||
const percentEl = document.getElementById('tokenBudgetPercent');
|
const percentEl = document.getElementById('tokenBudgetPercent');
|
||||||
|
const barWrap = bar ? bar.parentElement : null;
|
||||||
|
if (unlimited) {
|
||||||
|
if (bar) bar.style.width = '0%';
|
||||||
|
if (percentEl) percentEl.textContent = 'Unbegrenzt';
|
||||||
|
if (barWrap) barWrap.style.opacity = '0.4';
|
||||||
|
} else {
|
||||||
const percent = budget.credits_percent_used || 0;
|
const percent = budget.credits_percent_used || 0;
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.style.width = Math.min(100, percent) + '%';
|
bar.style.width = Math.min(100, percent) + '%';
|
||||||
bar.classList.remove('warning', 'critical');
|
bar.classList.remove('warning', 'critical', 'over-limit');
|
||||||
if (percent > 80) bar.classList.add('critical');
|
if (percent > 100) bar.classList.add('over-limit');
|
||||||
|
else if (percent > 80) bar.classList.add('critical');
|
||||||
else if (percent > 50) bar.classList.add('warning');
|
else if (percent > 50) bar.classList.add('warning');
|
||||||
}
|
}
|
||||||
if (percentEl) percentEl.textContent = percent.toFixed(1) + '%';
|
if (percentEl) {
|
||||||
|
percentEl.textContent = percent > 100
|
||||||
|
? ('UEBER LIMIT (' + percent.toFixed(1) + '%)')
|
||||||
|
: (percent.toFixed(1) + '%');
|
||||||
|
percentEl.style.color = percent > 100 ? 'var(--danger-text, #991b1b)' : '';
|
||||||
|
percentEl.style.fontWeight = percent > 100 ? '700' : '';
|
||||||
|
}
|
||||||
|
if (barWrap) barWrap.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
fillBudgetForm(budget);
|
fillBudgetForm(budget);
|
||||||
|
|
||||||
@@ -655,6 +681,37 @@ function fillBudgetForm(budget) {
|
|||||||
el('editCostPerCredit', budget.cost_per_credit);
|
el('editCostPerCredit', budget.cost_per_credit);
|
||||||
el('editBudgetUsd', budget.token_budget_usd);
|
el('editBudgetUsd', budget.token_budget_usd);
|
||||||
el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0);
|
el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0);
|
||||||
|
const cb = document.getElementById('editUnlimitedBudget');
|
||||||
|
if (cb) {
|
||||||
|
cb.checked = !!budget.unlimited_budget;
|
||||||
|
onUnlimitedToggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited-Toggle: Felder ausgrauen wenn aktiv
|
||||||
|
function onUnlimitedToggle() {
|
||||||
|
const cb = document.getElementById('editUnlimitedBudget');
|
||||||
|
const isUnlimited = cb && cb.checked;
|
||||||
|
['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) {
|
||||||
|
const e = document.getElementById(id);
|
||||||
|
if (e) {
|
||||||
|
e.disabled = isUnlimited;
|
||||||
|
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited-Toggle im Lizenz-Modal
|
||||||
|
function onNewLicUnlimitedToggle() {
|
||||||
|
const cb = document.getElementById('newLicUnlimited');
|
||||||
|
const isUnlimited = cb && cb.checked;
|
||||||
|
['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) {
|
||||||
|
const e = document.getElementById(id);
|
||||||
|
if (e) {
|
||||||
|
e.disabled = isUnlimited;
|
||||||
|
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboardTokenStats() {
|
async function loadDashboardTokenStats() {
|
||||||
@@ -701,19 +758,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body = {};
|
var body = {};
|
||||||
|
var unlimitedCb = document.getElementById('editUnlimitedBudget');
|
||||||
|
var isUnlimited = !!(unlimitedCb && unlimitedCb.checked);
|
||||||
|
body.unlimited_budget = isUnlimited;
|
||||||
|
|
||||||
var creditsTotal = document.getElementById('editCreditsTotal');
|
var creditsTotal = document.getElementById('editCreditsTotal');
|
||||||
var costPerCredit = document.getElementById('editCostPerCredit');
|
var costPerCredit = document.getElementById('editCostPerCredit');
|
||||||
var budgetUsd = document.getElementById('editBudgetUsd');
|
var budgetUsd = document.getElementById('editBudgetUsd');
|
||||||
var creditsUsed = document.getElementById('editCreditsUsed');
|
var creditsUsed = document.getElementById('editCreditsUsed');
|
||||||
|
|
||||||
|
if (!isUnlimited) {
|
||||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||||
|
}
|
||||||
|
// credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen)
|
||||||
if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value);
|
if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value);
|
||||||
|
|
||||||
await API.put('/api/token-usage/budget/' + activeLic.id, body);
|
var result = await API.put('/api/token-usage/budget/' + activeLic.id, body);
|
||||||
if (msgEl) msgEl.textContent = 'Gespeichert!';
|
if (msgEl) {
|
||||||
setTimeout(function() { if (msgEl) msgEl.textContent = ''; }, 3000);
|
if (result && result.warning) {
|
||||||
|
msgEl.textContent = result.warning;
|
||||||
|
msgEl.style.color = 'var(--danger-text, #991b1b)';
|
||||||
|
} else {
|
||||||
|
msgEl.textContent = 'Gespeichert!';
|
||||||
|
msgEl.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(function() { if (msgEl) { msgEl.textContent = ''; msgEl.style.color = ''; } }, 5000);
|
||||||
|
|
||||||
// Daten neu laden
|
// Daten neu laden
|
||||||
loadOrgTokenUsage(currentOrgId);
|
loadOrgTokenUsage(currentOrgId);
|
||||||
|
|||||||
229
src/static/js/audit.js
Normale Datei
229
src/static/js/audit.js
Normale Datei
@@ -0,0 +1,229 @@
|
|||||||
|
/* Audit-Log Tab */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let auditCache = { items: [], total: 0, offset: 0, limit: 200 };
|
||||||
|
let auditDistinct = { actions: [], resource_types: [], admins: [] };
|
||||||
|
let expandedRows = new Set();
|
||||||
|
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
create: "Erstellt",
|
||||||
|
update: "Geändert",
|
||||||
|
delete: "Gelöscht",
|
||||||
|
login_success: "Login erfolgreich",
|
||||||
|
login_failed: "Login fehlgeschlagen",
|
||||||
|
login_blocked: "Login blockiert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESOURCE_LABELS = {
|
||||||
|
organization: "Organisation",
|
||||||
|
user: "Nutzer",
|
||||||
|
license: "Lizenz",
|
||||||
|
source: "Quelle",
|
||||||
|
admin: "Admin",
|
||||||
|
auth: "Auth",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_BADGE_CLASS = {
|
||||||
|
create: "active",
|
||||||
|
update: "trial",
|
||||||
|
delete: "inactive",
|
||||||
|
login_success: "active",
|
||||||
|
login_failed: "inactive",
|
||||||
|
login_blocked: "inactive",
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.querySelectorAll('.nav-tab[data-section="audit"]').forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => loadAudit());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter-Inputs verdrahten
|
||||||
|
["auditFilterAction", "auditFilterResource", "auditFilterAdmin",
|
||||||
|
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = document.getElementById("auditFilterReset");
|
||||||
|
if (reset) reset.addEventListener("click", () => {
|
||||||
|
["auditFilterAction", "auditFilterResource", "auditFilterAdmin",
|
||||||
|
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = "";
|
||||||
|
});
|
||||||
|
auditCache.offset = 0;
|
||||||
|
loadAudit();
|
||||||
|
});
|
||||||
|
|
||||||
|
const prev = document.getElementById("auditPrevBtn");
|
||||||
|
const next = document.getElementById("auditNextBtn");
|
||||||
|
if (prev) prev.addEventListener("click", () => {
|
||||||
|
auditCache.offset = Math.max(0, auditCache.offset - auditCache.limit);
|
||||||
|
loadAudit();
|
||||||
|
});
|
||||||
|
if (next) next.addEventListener("click", () => {
|
||||||
|
if (auditCache.offset + auditCache.limit < auditCache.total) {
|
||||||
|
auditCache.offset += auditCache.limit;
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAuditDistinct() {
|
||||||
|
try {
|
||||||
|
auditDistinct = await API.get("/api/audit-log/distinct");
|
||||||
|
|
||||||
|
const actSel = document.getElementById("auditFilterAction");
|
||||||
|
if (actSel && actSel.options.length <= 1) {
|
||||||
|
auditDistinct.actions.forEach((a) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = a;
|
||||||
|
opt.textContent = ACTION_LABELS[a] || a;
|
||||||
|
actSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resSel = document.getElementById("auditFilterResource");
|
||||||
|
if (resSel && resSel.options.length <= 1) {
|
||||||
|
auditDistinct.resource_types.forEach((r) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = r;
|
||||||
|
opt.textContent = RESOURCE_LABELS[r] || r;
|
||||||
|
resSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const admSel = document.getElementById("auditFilterAdmin");
|
||||||
|
if (admSel && admSel.options.length <= 1) {
|
||||||
|
auditDistinct.admins.forEach((a) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = a.username;
|
||||||
|
admSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Audit-Filter laden fehlgeschlagen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
await loadAuditDistinct();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const action = document.getElementById("auditFilterAction")?.value;
|
||||||
|
const resource = document.getElementById("auditFilterResource")?.value;
|
||||||
|
const adminId = document.getElementById("auditFilterAdmin")?.value;
|
||||||
|
const from = document.getElementById("auditFilterFrom")?.value;
|
||||||
|
const to = document.getElementById("auditFilterTo")?.value;
|
||||||
|
|
||||||
|
if (action) params.append("action", action);
|
||||||
|
if (resource) params.append("resource_type", resource);
|
||||||
|
if (adminId) params.append("admin_id", adminId);
|
||||||
|
if (from) params.append("from_ts", from);
|
||||||
|
if (to) params.append("to_ts", to);
|
||||||
|
params.append("limit", auditCache.limit);
|
||||||
|
params.append("offset", auditCache.offset);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.get("/api/audit-log?" + params.toString());
|
||||||
|
auditCache.items = data.items;
|
||||||
|
auditCache.total = data.total;
|
||||||
|
auditCache.offset = data.offset;
|
||||||
|
renderAudit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Audit-Log laden fehlgeschlagen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditTs(ts) {
|
||||||
|
if (!ts) return "-";
|
||||||
|
try {
|
||||||
|
const d = new Date(ts.replace(" ", "T") + "Z");
|
||||||
|
return d.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditDiff(before, after) {
|
||||||
|
if (before === null && after === null) return "—";
|
||||||
|
if (before === null && after !== null) {
|
||||||
|
// CREATE: voller Datensatz
|
||||||
|
const keys = Object.keys(after).filter(k => k !== "password_hash");
|
||||||
|
return '<table class="audit-diff"><tbody>' +
|
||||||
|
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(after[k]))}</td></tr>`).join("") +
|
||||||
|
'</tbody></table>';
|
||||||
|
}
|
||||||
|
if (before !== null && after === null) {
|
||||||
|
const keys = Object.keys(before).filter(k => k !== "password_hash");
|
||||||
|
return '<table class="audit-diff"><tbody>' +
|
||||||
|
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(before[k]))}</td></tr>`).join("") +
|
||||||
|
'</tbody></table>';
|
||||||
|
}
|
||||||
|
// UPDATE: Diff zeigen
|
||||||
|
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
|
||||||
|
return '<table class="audit-diff"><thead><tr><th>Feld</th><th>vorher</th><th>nachher</th></tr></thead><tbody>' +
|
||||||
|
[...keys].map(k => {
|
||||||
|
const bv = (before || {})[k];
|
||||||
|
const av = (after || {})[k];
|
||||||
|
return `<tr><td class="diff-key">${esc(k)}</td><td class="diff-old">${esc(JSON.stringify(bv))}</td><td class="diff-new">${esc(JSON.stringify(av))}</td></tr>`;
|
||||||
|
}).join("") +
|
||||||
|
'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudit() {
|
||||||
|
const tbody = document.getElementById("auditTable");
|
||||||
|
const countEl = document.getElementById("auditCount");
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (auditCache.items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine Eintraege</td></tr>';
|
||||||
|
if (countEl) countEl.textContent = "0 Eintraege";
|
||||||
|
updateAuditPaginator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
auditCache.items.forEach((it) => {
|
||||||
|
const actLabel = ACTION_LABELS[it.action] || it.action;
|
||||||
|
const resLabel = it.resource_type ? (RESOURCE_LABELS[it.resource_type] || it.resource_type) : "—";
|
||||||
|
const resId = it.resource_id ? "#" + it.resource_id : "";
|
||||||
|
const badgeCls = ACTION_BADGE_CLASS[it.action] || "trial";
|
||||||
|
const expanded = expandedRows.has(it.id);
|
||||||
|
const arrow = expanded ? "▼" : "▶";
|
||||||
|
|
||||||
|
html += `<tr class="audit-row" onclick="toggleAuditRow(${it.id})">
|
||||||
|
<td>${formatAuditTs(it.ts)}</td>
|
||||||
|
<td>${esc(it.admin_username || "—")}</td>
|
||||||
|
<td>${esc(it.ip || "—")}</td>
|
||||||
|
<td><span class="badge badge-${badgeCls}">${esc(actLabel)}</span></td>
|
||||||
|
<td>${esc(resLabel)} ${esc(resId)}</td>
|
||||||
|
<td style="text-align:right;color:var(--text-secondary)">${arrow}</td>
|
||||||
|
</tr>`;
|
||||||
|
if (expanded) {
|
||||||
|
html += `<tr class="audit-detail-row" id="audit-detail-${it.id}"><td colspan="6">${renderAuditDiff(it.before, it.after)}</td></tr>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
|
||||||
|
if (countEl) {
|
||||||
|
const start = auditCache.offset + 1;
|
||||||
|
const end = Math.min(auditCache.offset + auditCache.items.length, auditCache.total);
|
||||||
|
countEl.textContent = `${start}-${end} von ${auditCache.total}`;
|
||||||
|
}
|
||||||
|
updateAuditPaginator();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuditRow(id) {
|
||||||
|
if (expandedRows.has(id)) expandedRows.delete(id);
|
||||||
|
else expandedRows.add(id);
|
||||||
|
renderAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAuditPaginator() {
|
||||||
|
const prev = document.getElementById("auditPrevBtn");
|
||||||
|
const next = document.getElementById("auditNextBtn");
|
||||||
|
if (prev) prev.disabled = auditCache.offset <= 0;
|
||||||
|
if (next) next.disabled = auditCache.offset + auditCache.limit >= auditCache.total;
|
||||||
|
}
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren