feat(kontingent): Credits-Stellwerte je Lizenz + Lizenz beim Org-Anlegen
Bedienseite für das Credits-Monatskontingent des Monitors. Das Portal setzt jetzt alle Stellwerte, der Monitor bleibt Herr der Laufzeitfelder. - Lizenz-Modal und Budget-Formular um Abrechnungszeitraum (monthly/total), Übertrag-Checkbox und Warnschwelle erweitert, Vorbelegungen auf das beschlossene Modell (10.000 Credits, monatlich, Übertrag an, 0,20 USD). - Neue Organisation kann direkt eine Lizenz mit Kontingent anlegen (OrgCreate.license, verschachtelt). Org- und Lizenz-Insert laufen in EINER Transaktion, schlägt der Lizenz-Teil fehl, entsteht keine halbe Organisation. Gemeinsamer Helper insert_license_row statt Duplikation der Widerruf- und Audit-Logik. - PUT /api/token-usage/budget validiert credits_period (monthly|total) und budget_warning_percent (1 bis 100), credits_rollover wird wie unlimited_budget normalisiert. Laufzeitfelder bleiben bewusst außerhalb der Whitelist. - Overview und Org-Detail rechnen den Rest jetzt gegen verfügbar = Kontingent + Übertrag, dieselbe Bezugsgröße wie der Hard-Stop im Monitor. Neuer read-only Block Laufzeitwerte im Token-Subtab. - Startup-Check loggt einen deutlichen Fehler, wenn die licenses-Tabelle die Credits-Spalten noch nicht hat (falsche Deploy-Reihenfolge). - Migration migrations/2026-07-25_credits_period.py (idempotent, wortgleich mit der Monitor-Migration) für die Portal-Staging-DB, auf Live gilt: Monitor VOR Portal promoten. - 13 neue Modell-Tests, pytest grün (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert seit vorher). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
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.unlimited_budget,
|
||||
l.credits_period, l.credits_rollover, l.credits_carried, l.credits_period_start,
|
||||
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,
|
||||
@@ -36,9 +37,14 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
for row in rows:
|
||||
credits_total = row["credits_total"] or 0
|
||||
credits_used = row["credits_used"] or 0
|
||||
credits_carried = row["credits_carried"] or 0
|
||||
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)
|
||||
# Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim
|
||||
# Hard-Stop inklusive Uebertrag, die Anzeige muss dieselbe
|
||||
# Bezugsgroesse nutzen, sonst zeigt sie zu wenig Rest an.
|
||||
credits_available = credits_total + credits_carried
|
||||
credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None)
|
||||
percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None)
|
||||
budget_usd = row["token_budget_usd"]
|
||||
cost = row["total_cost"]
|
||||
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
|
||||
@@ -51,6 +57,11 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
"credits_used": round(credits_used, 1),
|
||||
"credits_remaining": credits_remaining,
|
||||
"credits_percent_used": percent_used,
|
||||
"credits_available": credits_available,
|
||||
"credits_carried": credits_carried,
|
||||
"credits_period": row["credits_period"] or "monthly",
|
||||
"credits_rollover": bool(row["credits_rollover"]),
|
||||
"credits_period_start": row["credits_period_start"],
|
||||
"token_budget_usd": budget_usd,
|
||||
"total_cost_usd": round(cost, 2),
|
||||
"budget_percent_used": budget_percent,
|
||||
@@ -122,13 +133,19 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
}
|
||||
|
||||
cursor = await db.execute(
|
||||
"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",
|
||||
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, "
|
||||
"credits_period, credits_rollover, credits_carried, credits_period_start "
|
||||
"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
|
||||
credits_carried = (lic["credits_carried"] or 0) if lic else 0
|
||||
# Verfuegbar = Kontingent plus Uebertrag, dieselbe Bezugsgroesse wie
|
||||
# der Hard-Stop im Monitor.
|
||||
credits_available = (credits_total or 0) + credits_carried
|
||||
|
||||
return {
|
||||
"year_month": year_month,
|
||||
@@ -144,8 +161,13 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
"unlimited_budget": unlimited,
|
||||
"credits_total": credits_total,
|
||||
"credits_used": round(credits_used, 1) if credits_used else 0,
|
||||
"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),
|
||||
"credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None),
|
||||
"credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None),
|
||||
"credits_available": credits_available,
|
||||
"credits_carried": credits_carried,
|
||||
"credits_period": (lic["credits_period"] or "monthly") if lic else "monthly",
|
||||
"credits_rollover": bool(lic["credits_rollover"]) if lic else False,
|
||||
"credits_period_start": lic["credits_period_start"] 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,
|
||||
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
|
||||
@@ -164,9 +186,23 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
|
||||
if not before:
|
||||
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
||||
|
||||
# Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen.
|
||||
# Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und
|
||||
# der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset).
|
||||
if "credits_period" in data and data["credits_period"] not in ("monthly", "total"):
|
||||
raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein")
|
||||
if "budget_warning_percent" in data:
|
||||
try:
|
||||
pct = int(data["budget_warning_percent"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein")
|
||||
if not 1 <= pct <= 100:
|
||||
raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 1 und 100 liegen")
|
||||
data["budget_warning_percent"] = pct
|
||||
|
||||
fields = []
|
||||
values = []
|
||||
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
|
||||
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period"):
|
||||
if key in data:
|
||||
fields.append(f"{key} = ?")
|
||||
values.append(data[key])
|
||||
@@ -179,6 +215,13 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
|
||||
fields.append("unlimited_budget = ?")
|
||||
values.append(1 if data["unlimited_budget"] else 0)
|
||||
|
||||
if "credits_rollover" in data:
|
||||
fields.append("credits_rollover = ?")
|
||||
values.append(1 if data["credits_rollover"] else 0)
|
||||
|
||||
# Bewusst NICHT aenderbar: credits_carried, credits_period_start und
|
||||
# budget_warning_sent sind Laufzeitfelder, die verwaltet der Monitor.
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren