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:
@@ -1,9 +1,10 @@
|
||||
"""Token-Usage & Budget-Verwaltung."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from auth import get_current_admin
|
||||
from database import get_db
|
||||
from audit import log_action, get_client_ip, row_to_dict
|
||||
|
||||
logger = logging.getLogger("verwaltung.token_usage")
|
||||
router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"])
|
||||
@@ -15,10 +16,10 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
SELECT
|
||||
o.id, o.name, o.slug,
|
||||
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.input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
|
||||
@@ -30,17 +31,18 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
GROUP BY o.id
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
credits_total = row["credits_total"] or 0
|
||||
credits_used = row["credits_used"] or 0
|
||||
credits_remaining = max(0, int(credits_total - credits_used)) if credits_total else None
|
||||
percent_used = round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None
|
||||
unlimited = bool(row["unlimited_budget"])
|
||||
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"]
|
||||
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({
|
||||
"org_id": row["id"],
|
||||
"org_name": row["name"],
|
||||
@@ -53,6 +55,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
"total_cost_usd": round(cost, 2),
|
||||
"budget_percent_used": budget_percent,
|
||||
"budget_warning_percent": row["budget_warning_percent"] or 80,
|
||||
"unlimited_budget": unlimited,
|
||||
"total_input_tokens": row["total_input_tokens"],
|
||||
"total_output_tokens": row["total_output_tokens"],
|
||||
"total_api_calls": row["total_api_calls"],
|
||||
@@ -73,7 +76,7 @@ async def get_org_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
"SELECT * FROM token_usage_monthly WHERE organization_id = ? ORDER BY year_month DESC",
|
||||
(org_id,))
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
|
||||
return [{
|
||||
"year_month": row["year_month"],
|
||||
"source": row["source"] if "source" in row.keys() else "monitor",
|
||||
@@ -95,12 +98,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
db = await get_db()
|
||||
try:
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?",
|
||||
(org_id, year_month))
|
||||
usage_rows = await cursor.fetchall()
|
||||
# Summe ueber alle Sources
|
||||
usage = {
|
||||
"input_tokens": sum(r["input_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),
|
||||
"refresh_count": sum(r["refresh_count"] for r in usage_rows),
|
||||
}
|
||||
# Per-Source Aufschluesselung
|
||||
usage_by_source = {}
|
||||
for r in usage_rows:
|
||||
src = r["source"] if "source" in r.keys() else "monitor"
|
||||
@@ -119,15 +120,16 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
"api_calls": r["api_calls"],
|
||||
"refresh_count": r["refresh_count"],
|
||||
}
|
||||
|
||||
|
||||
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,))
|
||||
lic = await cursor.fetchone()
|
||||
|
||||
|
||||
unlimited = bool(lic["unlimited_budget"]) if lic else False
|
||||
credits_total = lic["credits_total"] if lic else None
|
||||
credits_used = lic["credits_used"] if lic else 0
|
||||
|
||||
|
||||
return {
|
||||
"year_month": year_month,
|
||||
"usage": {
|
||||
@@ -139,10 +141,11 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
},
|
||||
"usage_by_source": usage_by_source,
|
||||
"budget": {
|
||||
"unlimited_budget": unlimited,
|
||||
"credits_total": credits_total,
|
||||
"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_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),
|
||||
"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,
|
||||
"cost_per_credit": lic["cost_per_credit"] if lic else None,
|
||||
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
|
||||
@@ -153,33 +156,52 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
|
||||
|
||||
@router.put("/budget/{license_id}")
|
||||
async def update_budget(license_id: int, data: dict, admin=Depends(get_current_admin)):
|
||||
"""Budget einer Lizenz setzen/ändern."""
|
||||
async def update_budget(license_id: int, data: dict, request: Request, admin=Depends(get_current_admin)):
|
||||
"""Budget einer Lizenz setzen/aendern."""
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute("SELECT id FROM licenses WHERE id = ?", (license_id,))
|
||||
if not await cursor.fetchone():
|
||||
before = await row_to_dict(db, "licenses", license_id)
|
||||
if not before:
|
||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||
|
||||
|
||||
fields = []
|
||||
values = []
|
||||
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
|
||||
if key in data:
|
||||
fields.append(f"{key} = ?")
|
||||
values.append(data[key])
|
||||
|
||||
|
||||
if "credits_used" in data:
|
||||
fields.append("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:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
|
||||
values.append(license_id)
|
||||
await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Budget für Lizenz {license_id} aktualisiert: {data}")
|
||||
return {"ok": True}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# 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:
|
||||
await db.close()
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren