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:
claude-dev
2026-07-25 13:11:02 +00:00
Ursprung c95903fb3a
Commit e0c39de21f
10 geänderte Dateien mit 566 neuen und 92 gelöschten Zeilen

Datei anzeigen

@@ -46,11 +46,11 @@ src/:
routers/: routers/:
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)" licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)"
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de" users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge" sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge"
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
token_usage.py: "Token-Verbrauch pro Org/Monat, Budget-Steuerung" token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
audit.py: "Audit-Log-Abfrage, Filter" audit.py: "Audit-Log-Abfrage, Filter"
email_utils/: email_utils/:
@@ -70,6 +70,7 @@ src/:
migrations/: migrations/:
einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)" einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)"
2026-07-25_credits_period.py: "Credits-Perioden-Spalten in licenses (idempotent, identisch zur Monitor-Migration). Nötig für die Portal-Staging-DB, auf Live legt sie normalerweise der Monitor an. Reihenfolge: Monitor VOR Portal promoten."
``` ```
## Datenbank-Tabellen (relevant fürs Portal) ## Datenbank-Tabellen (relevant fürs Portal)

Datei anzeigen

@@ -0,0 +1,57 @@
"""Migration 2026-07-25: Credits-Perioden-Spalten in licenses.
Legt die fünf Spalten des Credits-Monatskontingents an, exakt dieselben
Statements wie die Monitor-Migration (AegisSight-Monitor, src/database.py,
Block "Credits-Periode"). Idempotent, ein zweiter Lauf ist ein No-op.
Hintergrund: Der Monitor legt diese Spalten beim Start selbst an. Die
Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, auf der kein
Monitor läuft, deshalb braucht sie dieses Skript. Auf der Live-DB ist es
nur ein Fallback für den Fall, dass das Portal vor dem Monitor deployt wird.
Ausführung:
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_credits_period.py
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_credits_period.py
"""
import os
import sqlite3
import sys
# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration
COLUMNS = {
"credits_period": "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'",
"credits_period_start": "ALTER TABLE licenses ADD COLUMN credits_period_start TEXT",
"credits_rollover": "ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0",
"credits_carried": "ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0",
"budget_warning_sent": "ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0",
}
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
cols = [c[1] for c in conn.execute("PRAGMA table_info(licenses)")]
for name, stmt in COLUMNS.items():
if name not in cols:
conn.execute(stmt)
print(f" + licenses.{name} Spalte hinzugefügt")
else:
print(f" = licenses.{name} war bereits da")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

Datei anzeigen

@@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Schutz gegen falsche Deploy-Reihenfolge: Die Credits-Spalten in licenses
# legt die Monitor-Migration an. Fehlen sie, scheitern Lizenz-Anlage und
# Token-Tab mit "no such column". Hier nur ein deutlicher Log-Hinweis,
# kein Hard-Fail.
try:
from database import get_db
db = await get_db()
try:
cursor = await db.execute("PRAGMA table_info(licenses)")
cols = [row[1] for row in await cursor.fetchall()]
if "credits_period" not in cols:
logger.error(
"licenses-Tabelle ohne Credits-Spalten (credits_period fehlt)! "
"Zuerst den Monitor deployen (dessen Migration legt sie an) "
"oder migrations/2026-07-25_credits_period.py gegen diese DB fahren."
)
finally:
await db.close()
except Exception as exc:
logger.warning("Startup-Schema-Check uebersprungen: %s", exc)
logger.info("Verwaltungsportal gestartet auf Port %s", PORT) logger.info("Verwaltungsportal gestartet auf Port %s", PORT)
yield yield
logger.info("Verwaltungsportal beendet") logger.info("Verwaltungsportal beendet")

Datei anzeigen

@@ -22,10 +22,36 @@ class TokenResponse(BaseModel):
email: str = "" email: str = ""
class LicenseParams(BaseModel):
"""Stellwerte einer Lizenz ohne Org-Bezug.
Wird zweifach genutzt, direkt beim Lizenz-Anlegen (LicenseCreate) und
verschachtelt beim Org-Anlegen (OrgCreate.license). Die Laufzeitfelder
(credits_used, credits_carried, credits_period_start, budget_warning_sent)
verwaltet der Monitor, das Portal setzt nur Stellwerte.
"""
license_type: str = Field(pattern="^(trial|annual|permanent)$")
max_users: int = Field(default=5, ge=1, le=1000)
duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
unlimited_budget: bool = False
# 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt (der Monitor
# setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit.
credits_period: str = Field(default="monthly", pattern="^(monthly|total)$")
# Uebertrag ungenutzter Credits in den Folgemonat, der Monitor deckelt
# auf ein Monatskontingent. Empfohlen an, faengt Krisenspitzen ab.
credits_rollover: bool = True
class OrgCreate(BaseModel): class OrgCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$")
output_language: str = Field(default="de", pattern="^(de|en)$") output_language: str = Field(default="de", pattern="^(de|en)$")
# Optional direkt eine Lizenz mit Credits-Kontingent anlegen
license: Optional[LicenseParams] = None
class OrgUpdate(BaseModel): class OrgUpdate(BaseModel):
@@ -48,16 +74,8 @@ class OrgResponse(BaseModel):
output_language: str = "de" output_language: str = "de"
class LicenseCreate(BaseModel): class LicenseCreate(LicenseParams):
organization_id: int organization_id: int
license_type: str = Field(pattern="^(trial|annual|permanent)$")
max_users: int = Field(default=5, ge=1, le=1000)
duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
unlimited_budget: bool = False
class LicenseResponse(BaseModel): class LicenseResponse(BaseModel):
@@ -75,6 +93,12 @@ class LicenseResponse(BaseModel):
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 unlimited_budget: bool = False
# Neue Kontingent-Felder, alle optional mit Default, damit Responses auch
# von einer noch nicht migrierten Datenbank validieren.
credits_period: Optional[str] = "monthly"
credits_rollover: Optional[bool] = False
credits_carried: Optional[float] = 0
credits_period_start: Optional[str] = None
created_at: str created_at: str
globe_access: bool = False globe_access: bool = False
network_access: bool = False network_access: bool = False

Datei anzeigen

@@ -1,7 +1,7 @@
"""Lizenz-CRUD.""" """Lizenz-CRUD."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from models import LicenseCreate, LicenseResponse from models import LicenseCreate, LicenseParams, 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 from audit import log_action, get_client_ip, row_to_dict
@@ -10,6 +10,90 @@ import aiosqlite
router = APIRouter(prefix="/api/licenses", tags=["licenses"]) router = APIRouter(prefix="/api/licenses", tags=["licenses"])
async def insert_license_row(
db: aiosqlite.Connection, org_id: int, params: LicenseParams
) -> tuple[int, list[dict]]:
"""Widerruft aktive Lizenzen der Org und legt die neue Zeile an.
Committet bewusst NICHT, die Transaktionsgrenze bestimmt der Aufrufer.
So kann das Org-Anlegen Organisation und Lizenz atomar anlegen.
Gibt (neue Lizenz-ID, Snapshots der widerrufenen Lizenzen) zurueck,
die Audit-Eintraege schreibt audit_license_creation() nach dem Commit.
"""
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
revoked_lics = [dict(r) for r in await cursor.fetchall()]
if revoked_lics:
await db.execute(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
now = datetime.now(timezone.utc)
valid_from = now.isoformat()
valid_until = None
if params.license_type == "permanent":
valid_until = None
elif params.duration_days:
valid_until = (now + timedelta(days=params.duration_days)).isoformat()
elif params.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif params.license_type == "annual":
valid_until = (now + timedelta(days=365)).isoformat()
# Bei unlimited_budget: Kontingent-Felder ignorieren, Uebertrag aus
if params.unlimited_budget:
token_budget_usd = None
credits_total = None
cost_per_credit = None
credits_rollover = 0
else:
token_budget_usd = params.token_budget_usd
credits_total = params.credits_total
cost_per_credit = params.cost_per_credit
credits_rollover = 1 if params.credits_rollover else 0
# credits_period_start bleibt bewusst NULL, der Monitor setzt die
# Periodenmarke traege bei der naechsten Lizenzpruefung selbst.
cursor = await db.execute(
"""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, unlimited_budget,
credits_period, credits_rollover)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)""",
(org_id, params.license_type, params.max_users, valid_from, valid_until,
token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent,
1 if params.unlimited_budget else 0,
params.credits_period, credits_rollover),
)
return cursor.lastrowid, revoked_lics
async def audit_license_creation(
db: aiosqlite.Connection, admin: dict, ip: str, lic_id: int, revoked_lics: list[dict]
) -> dict:
"""Schreibt die Audit-Eintraege fuers Lizenz-Anlegen (nach dem Commit)."""
for old_lic in revoked_lics:
new_lic = dict(old_lic)
new_lic["status"] = "revoked"
await log_action(
db, admin, ip,
action="update", resource_type="license", resource_id=old_lic["id"],
before=old_lic, after=new_lic,
)
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
new_lic = dict(await cursor.fetchone())
await log_action(
db, admin, ip,
action="create", resource_type="license", resource_id=lic_id,
after=new_lic,
)
return new_lic
@router.get("", response_model=list[LicenseResponse]) @router.get("", response_model=list[LicenseResponse])
async def list_licenses( async def list_licenses(
org_id: int = None, org_id: int = None,
@@ -39,68 +123,12 @@ async def create_license(
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 + Snapshot fuer Audit lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data)
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(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(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)
valid_from = now.isoformat()
valid_until = None
if data.license_type == "permanent":
valid_until = None
elif data.duration_days:
valid_until = (now + timedelta(days=data.duration_days)).isoformat()
elif data.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif data.license_type == "annual":
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(
"""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, unlimited_budget)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
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 = ?", (lic_id,)) return await audit_license_creation(
new_lic = dict(await cursor.fetchone()) db, admin, get_client_ip(request), lic_id, revoked_lics
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")

Datei anzeigen

@@ -64,6 +64,16 @@ async def create_organization(
(data.name, data.slug, now, now), (data.name, data.slug, now, now),
) )
org_id = cursor.lastrowid org_id = cursor.lastrowid
# Optional direkt eine Lizenz mit Credits-Kontingent anlegen. Beide Inserts
# liegen VOR dem Commit, schlaegt der Lizenz-Teil fehl (z.B. fehlende
# Spalten), entsteht keine halb angelegte Organisation.
lic_id = None
revoked_lics: list = []
if data.license is not None:
from routers.licenses import insert_license_row
lic_id, revoked_lics = await insert_license_row(db, org_id, data.license)
await db.commit() await db.commit()
# output_language als organization_settings-Eintrag persistieren # output_language als organization_settings-Eintrag persistieren
@@ -77,6 +87,11 @@ async def create_organization(
action="create", resource_type="organization", resource_id=org_id, action="create", resource_type="organization", resource_id=org_id,
after=dict(new_row_obj), after=dict(new_row_obj),
) )
if lic_id is not None:
from routers.licenses import audit_license_creation
await audit_license_creation(
db, admin, get_client_ip(request), lic_id, revoked_lics
)
return await _enrich_org(db, new_row_obj) return await _enrich_org(db, new_row_obj)

Datei anzeigen

@@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
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.unlimited_budget, 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.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,
@@ -36,9 +37,14 @@ 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_carried = row["credits_carried"] or 0
unlimited = bool(row["unlimited_budget"]) unlimited = bool(row["unlimited_budget"])
credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None) # Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim
percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None) # 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"] budget_usd = row["token_budget_usd"]
cost = row["total_cost"] 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) 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_used": round(credits_used, 1),
"credits_remaining": credits_remaining, "credits_remaining": credits_remaining,
"credits_percent_used": percent_used, "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, "token_budget_usd": budget_usd,
"total_cost_usd": round(cost, 2), "total_cost_usd": round(cost, 2),
"budget_percent_used": budget_percent, "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( 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,)) (org_id,))
lic = await cursor.fetchone() lic = await cursor.fetchone()
unlimited = bool(lic["unlimited_budget"]) if lic else False 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
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 { return {
"year_month": year_month, "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, "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": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total 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_total) * 100, 1) if credits_total and credits_total > 0 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, "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,
@@ -164,9 +186,23 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
if not before: if not before:
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") 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 = [] fields = []
values = [] 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: if key in data:
fields.append(f"{key} = ?") fields.append(f"{key} = ?")
values.append(data[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 = ?") fields.append("unlimited_budget = ?")
values.append(1 if data["unlimited_budget"] else 0) 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: if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")

Datei anzeigen

@@ -267,18 +267,35 @@
</div> </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. 10000">
</div>
<div class="form-group">
<label for="editCreditsPeriod">Abrechnungszeitraum</label>
<select id="editCreditsPeriod" onchange="onEditPeriodChange()">
<option value="monthly">Monatlich (füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group" style="display:flex; align-items:flex-end;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editCreditsRollover">
<span>Übertrag ungenutzter Credits in den Folgemonat</span>
</label>
</div>
<div class="form-group">
<label for="editWarnPercent">Warnschwelle (%)</label>
<input type="number" id="editWarnPercent" min="1" max="100" placeholder="80">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editCostPerCredit">Kosten pro Credit (USD)</label> <label for="editCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="editCostPerCredit" step="0.0001" placeholder="z.B. 0.0033"> <input type="number" id="editCostPerCredit" step="0.01" placeholder="0.20">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editBudgetUsd">Budget-Limit (USD)</label> <label for="editBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="editBudgetUsd" step="0.01" placeholder="z.B. 2000"> <input type="number" id="editBudgetUsd" step="0.01" placeholder="z.B. 2000">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editCreditsUsed">Verbrauchte Credits</label> <label for="editCreditsUsed">Verbrauchte Credits (laufende Periode)</label>
<input type="number" id="editCreditsUsed" step="0.01" placeholder="Aktueller Verbrauch"> <input type="number" id="editCreditsUsed" step="0.01" placeholder="Aktueller Verbrauch">
</div> </div>
<div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;"> <div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;">
@@ -288,6 +305,31 @@
</form> </form>
</div> </div>
</div> </div>
<h3 style="margin-top:24px;">Laufzeitwerte</h3>
<div class="card" style="margin-top:12px;">
<div class="card-body">
<p class="text-secondary" style="margin:0 0 12px; font-size:13px;">
Diese Werte verwaltet der Monitor selbst, sie sind hier nicht änderbar.
Stand ist die letzte Lizenzprüfung des Monitors, nach einem Monatswechsel
können sie kurzzeitig noch die Vorperiode zeigen.
</p>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px;">
<div class="form-group">
<label>Übertrag aus dem Vormonat</label>
<div id="viewCreditsCarried" style="font-size:16px; font-weight:600;">-</div>
</div>
<div class="form-group">
<label>Laufende Periode</label>
<div id="viewCreditsPeriodStart" style="font-size:16px; font-weight:600;">-</div>
</div>
<div class="form-group">
<label>Verfügbar gesamt (Kontingent + Übertrag)</label>
<div id="viewCreditsAvailable" style="font-size:16px; font-weight:600;">-</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -574,6 +616,63 @@
</select> </select>
<small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small> <small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small>
</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="newOrgWithLicense" checked onchange="onNewOrgLicenseToggle()">
<span><strong>Direkt eine Lizenz mit Credits-Kontingent anlegen</strong></span>
</label>
</div>
<div id="newOrgLicenseFields">
<div class="form-group">
<label for="newOrgLicType">Lizenztyp</label>
<select id="newOrgLicType" onchange="onNewOrgLicTypeChange()">
<option value="trial" selected>Trial</option>
<option value="annual">Jahreslizenz</option>
<option value="permanent">Permanent</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicMaxUsers">Maximale Nutzer</label>
<input type="number" id="newOrgLicMaxUsers" value="5" min="1" max="1000">
</div>
<div class="form-group" id="newOrgLicDurationGroup">
<label for="newOrgLicDuration">Laufzeit (Tage)</label>
<input type="number" id="newOrgLicDuration" value="14" min="1" max="3650">
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</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="newOrgLicUnlimited" onchange="onNewOrgLicUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; getrackt, aber kein Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newOrgLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newOrgLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newOrgLicCreditsPeriod" onchange="onNewOrgLicPeriodChange()">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgLicRollover" checked>
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicWarnPercent">Warnschwelle (%)</label>
<input type="number" id="newOrgLicWarnPercent" value="80" min="1" max="100">
</div>
<div class="form-group">
<label for="newOrgLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newOrgLicCostPerCredit" step="0.01" value="0.20" placeholder="0.20">
</div>
</div>
<div id="newOrgError" class="error-msg" style="display:none"></div> <div id="newOrgError" class="error-msg" style="display:none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -649,11 +748,28 @@
</div> </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" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newLicCreditsPeriod" onchange="onNewLicPeriodChange()">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newLicRollover" checked>
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
</label>
</div>
<div class="form-group">
<label for="newLicWarnPercent">Warnschwelle (%)</label>
<input type="number" id="newLicWarnPercent" value="80" min="1" max="100">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label> <label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033"> <input type="number" id="newLicCostPerCredit" step="0.01" placeholder="0.20">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="newLicBudgetUsd">Budget-Limit (USD)</label> <label for="newLicBudgetUsd">Budget-Limit (USD)</label>
@@ -1038,7 +1154,7 @@
</div> </div>
</div> </div>
<script src="/static/js/app.js?v=20260522a"></script> <script src="/static/js/app.js?v=20260725a"></script>
<script src="/static/js/sources.js?v=20260522x2"></script> <script src="/static/js/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script> <script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script> <script src="/static/js/source-health.js?v=20260509l"></script>

Datei anzeigen

@@ -442,14 +442,42 @@ function setupForms() {
e.preventDefault(); e.preventDefault();
const errEl = document.getElementById("newOrgError"); const errEl = document.getElementById("newOrgError");
errEl.style.display = "none"; errEl.style.display = "none";
const body = {
name: document.getElementById("newOrgName").value,
slug: document.getElementById("newOrgSlug").value,
output_language: document.getElementById("newOrgLanguage").value || "de",
};
// Optional direkt eine Lizenz mit Credits-Kontingent mitgeben
const withLic = document.getElementById("newOrgWithLicense");
if (withLic && withLic.checked) {
const licType = document.getElementById("newOrgLicType").value;
const lic = {
license_type: licType,
max_users: parseInt(document.getElementById("newOrgLicMaxUsers").value) || 5,
unlimited_budget: !!document.getElementById("newOrgLicUnlimited").checked,
// Periode und Uebertrag IMMER senden, sonst laesst sich der
// Uebertrag ueber das Formular nie abschalten
credits_period: document.getElementById("newOrgLicCreditsPeriod").value,
credits_rollover: !!document.getElementById("newOrgLicRollover").checked,
};
if (licType !== "permanent") {
lic.duration_days = parseInt(document.getElementById("newOrgLicDuration").value) || undefined;
}
if (!lic.unlimited_budget) {
const ct = document.getElementById("newOrgLicCreditsTotal");
const cpc = document.getElementById("newOrgLicCostPerCredit");
if (ct && ct.value) lic.credits_total = parseInt(ct.value);
if (cpc && cpc.value) lic.cost_per_credit = parseFloat(cpc.value);
}
const warn = document.getElementById("newOrgLicWarnPercent");
if (warn && warn.value) lic.budget_warning_percent = parseInt(warn.value);
body.license = lic;
}
try { try {
await API.post("/api/orgs", { await API.post("/api/orgs", body);
name: document.getElementById("newOrgName").value,
slug: document.getElementById("newOrgSlug").value,
output_language: document.getElementById("newOrgLanguage").value || "de",
});
closeModal("modalNewOrg"); closeModal("modalNewOrg");
document.getElementById("newOrgForm").reset(); document.getElementById("newOrgForm").reset();
onNewOrgLicenseToggle();
loadOrgs(); loadOrgs();
loadDashboard(); loadDashboard();
} catch (err) { } catch (err) {
@@ -515,6 +543,10 @@ function setupForms() {
} }
const unlimitedEl = document.getElementById('newLicUnlimited'); const unlimitedEl = document.getElementById('newLicUnlimited');
body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked); body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked);
// Periode und Uebertrag IMMER senden (nicht nur bei gesetztem Wert),
// sonst laesst sich der Uebertrag nie abschalten
body.credits_period = document.getElementById('newLicCreditsPeriod').value;
body.credits_rollover = !!document.getElementById('newLicRollover').checked;
if (!body.unlimited_budget) { if (!body.unlimited_budget) {
const creditsTotal = document.getElementById('newLicCreditsTotal'); const creditsTotal = document.getElementById('newLicCreditsTotal');
const costPerCredit = document.getElementById('newLicCostPerCredit'); const costPerCredit = document.getElementById('newLicCostPerCredit');
@@ -523,6 +555,8 @@ function setupForms() {
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);
} }
const warnEl = document.getElementById('newLicWarnPercent');
if (warnEl && warnEl.value) body.budget_warning_percent = parseInt(warnEl.value);
try { try {
await API.post("/api/licenses", body); await API.post("/api/licenses", body);
closeModal("modalNewLicense"); closeModal("modalNewLicense");
@@ -818,7 +852,11 @@ async function loadOrgTokenUsage(orgId) {
el('tokenCreditsRemaining', '∞ Unbegrenzt'); el('tokenCreditsRemaining', '∞ Unbegrenzt');
el('tokenBudgetUsd', '—'); el('tokenBudgetUsd', '—');
} else { } else {
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'); var remainingTxt = budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-';
if (budget.credits_carried > 0 && budget.credits_remaining != null) {
remainingTxt += ' (inkl. ' + Math.round(budget.credits_carried).toLocaleString('de-DE') + ' Übertrag)';
}
el('tokenCreditsRemaining', remainingTxt);
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) : '-');
@@ -887,37 +925,109 @@ 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);
el('editWarnPercent', budget.budget_warning_percent);
const periodSel = document.getElementById('editCreditsPeriod');
if (periodSel) periodSel.value = budget.credits_period || 'monthly';
const rolloverCb = document.getElementById('editCreditsRollover');
if (rolloverCb) rolloverCb.checked = !!budget.credits_rollover;
onEditPeriodChange();
const cb = document.getElementById('editUnlimitedBudget'); const cb = document.getElementById('editUnlimitedBudget');
if (cb) { if (cb) {
cb.checked = !!budget.unlimited_budget; cb.checked = !!budget.unlimited_budget;
onUnlimitedToggle(); onUnlimitedToggle();
} }
// Laufzeitwerte (verwaltet der Monitor, hier nur Anzeige)
const txt = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
const carried = budget.credits_carried || 0;
txt('viewCreditsCarried', carried > 0 ? Math.round(carried).toLocaleString('de-DE') + ' Credits' : 'Kein Übertrag');
txt('viewCreditsPeriodStart', budget.credits_period_start || 'Noch nicht initialisiert');
txt('viewCreditsAvailable', budget.unlimited_budget
? '∞ Unbegrenzt'
: (budget.credits_available != null ? Math.round(budget.credits_available).toLocaleString('de-DE') + ' Credits' : '-'));
} }
// Unlimited-Toggle: Felder ausgrauen wenn aktiv // Unlimited-Toggle: Felder ausgrauen wenn aktiv
function onUnlimitedToggle() { function onUnlimitedToggle() {
const cb = document.getElementById('editUnlimitedBudget'); const cb = document.getElementById('editUnlimitedBudget');
const isUnlimited = cb && cb.checked; const isUnlimited = cb && cb.checked;
['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) { ['editCreditsTotal','editCostPerCredit','editBudgetUsd','editCreditsPeriod','editCreditsRollover','editWarnPercent'].forEach(function(id) {
const e = document.getElementById(id); const e = document.getElementById(id);
if (e) { if (e) {
e.disabled = isUnlimited; e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1'; e.style.opacity = isUnlimited ? '0.4' : '1';
} }
}); });
if (!isUnlimited) onEditPeriodChange();
}
// Bei Periode "Gesamtlaufzeit" ist der Monats-Uebertrag bedeutungslos
function onEditPeriodChange() {
const sel = document.getElementById('editCreditsPeriod');
const cb = document.getElementById('editCreditsRollover');
if (!sel || !cb) return;
const unlimitedCb = document.getElementById('editUnlimitedBudget');
cb.disabled = sel.value === 'total' || !!(unlimitedCb && unlimitedCb.checked);
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
} }
// Unlimited-Toggle im Lizenz-Modal // Unlimited-Toggle im Lizenz-Modal
function onNewLicUnlimitedToggle() { function onNewLicUnlimitedToggle() {
const cb = document.getElementById('newLicUnlimited'); const cb = document.getElementById('newLicUnlimited');
const isUnlimited = cb && cb.checked; const isUnlimited = cb && cb.checked;
['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) { ['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd','newLicCreditsPeriod','newLicRollover','newLicWarnPercent'].forEach(function(id) {
const e = document.getElementById(id); const e = document.getElementById(id);
if (e) { if (e) {
e.disabled = isUnlimited; e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1'; e.style.opacity = isUnlimited ? '0.4' : '1';
} }
}); });
if (!isUnlimited) onNewLicPeriodChange();
}
function onNewLicPeriodChange() {
const sel = document.getElementById('newLicCreditsPeriod');
const cb = document.getElementById('newLicRollover');
if (!sel || !cb) return;
cb.disabled = sel.value === 'total';
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
}
// === Lizenz-Abschnitt im Org-Modal ===
function onNewOrgLicenseToggle() {
const cb = document.getElementById('newOrgWithLicense');
const fields = document.getElementById('newOrgLicenseFields');
if (fields) fields.style.display = (cb && cb.checked) ? '' : 'none';
}
function onNewOrgLicTypeChange() {
const type = document.getElementById('newOrgLicType').value;
const group = document.getElementById('newOrgLicDurationGroup');
if (group) group.style.display = type === 'permanent' ? 'none' : '';
if (type !== 'permanent') {
document.getElementById('newOrgLicDuration').value = type === 'trial' ? '14' : '365';
}
}
function onNewOrgLicUnlimitedToggle() {
const cb = document.getElementById('newOrgLicUnlimited');
const isUnlimited = cb && cb.checked;
['newOrgLicCreditsTotal','newOrgLicCostPerCredit','newOrgLicCreditsPeriod','newOrgLicRollover','newOrgLicWarnPercent'].forEach(function(id) {
const e = document.getElementById(id);
if (e) {
e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1';
}
});
if (!isUnlimited) onNewOrgLicPeriodChange();
}
function onNewOrgLicPeriodChange() {
const sel = document.getElementById('newOrgLicCreditsPeriod');
const cb = document.getElementById('newOrgLicRollover');
if (!sel || !cb) return;
cb.disabled = sel.value === 'total';
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
} }
async function loadDashboardTokenStats() { async function loadDashboardTokenStats() {
@@ -977,6 +1087,14 @@ document.addEventListener('DOMContentLoaded', function() {
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);
// Periode und Uebertrag IMMER senden (nicht ueber das if-value-Muster),
// sonst laesst sich der Uebertrag nie abschalten und die Periode nie
// zurueckstellen. Laufzeitfelder (credits_carried, credits_period_start,
// budget_warning_sent) sendet das Portal grundsaetzlich NICHT.
body.credits_period = document.getElementById('editCreditsPeriod').value;
body.credits_rollover = !!document.getElementById('editCreditsRollover').checked;
var warnPercent = document.getElementById('editWarnPercent');
if (warnPercent && warnPercent.value) body.budget_warning_percent = parseInt(warnPercent.value);
} }
// credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen) // 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);

Datei anzeigen

@@ -4,7 +4,7 @@ from pydantic import ValidationError
from models import ( from models import (
MagicLinkRequest, MagicLinkResponse, MagicLinkRequest, MagicLinkResponse,
VerifyTokenRequest, TokenResponse, VerifyTokenRequest, TokenResponse,
OrgCreate, LicenseCreate, UserCreate, OrgCreate, LicenseCreate, LicenseParams, LicenseResponse, UserCreate,
) )
@@ -46,6 +46,58 @@ def test_license_create_type_pattern():
LicenseCreate(organization_id=1, license_type="lifetime") LicenseCreate(organization_id=1, license_type="lifetime")
def test_license_params_credits_defaults():
"""Monatskontingent mit Übertrag ist die Voreinstellung."""
p = LicenseParams(license_type="trial")
assert p.credits_period == "monthly"
assert p.credits_rollover is True
assert p.budget_warning_percent == 80
def test_license_params_period_pattern():
LicenseParams(license_type="trial", credits_period="monthly")
LicenseParams(license_type="trial", credits_period="total")
with pytest.raises(ValidationError):
LicenseParams(license_type="trial", credits_period="weekly")
def test_license_create_inherits_credits_fields():
lic = LicenseCreate(
organization_id=1, license_type="annual",
credits_total=10000, credits_period="monthly", credits_rollover=False,
)
assert lic.credits_total == 10000
assert lic.credits_rollover is False
def test_org_create_without_license_still_valid():
"""Rückwärtskompatibilität, das Lizenz-Objekt ist optional."""
org = OrgCreate(name="Test", slug="test")
assert org.license is None
def test_org_create_with_nested_license():
org = OrgCreate(
name="Test", slug="test",
license={"license_type": "trial", "credits_total": 10000},
)
assert org.license.credits_total == 10000
assert org.license.credits_period == "monthly"
with pytest.raises(ValidationError):
OrgCreate(name="Test", slug="test", license={"license_type": "trial", "credits_period": "weekly"})
def test_license_response_without_new_columns():
"""Responses müssen auch von einer noch nicht migrierten DB validieren."""
r = LicenseResponse(
id=1, organization_id=1, license_type="trial", max_users=5,
valid_from="2026-07-25", valid_until=None, status="active",
notes=None, created_at="2026-07-25",
)
assert r.credits_period == "monthly"
assert r.credits_carried == 0
def test_user_create_role_pattern(): def test_user_create_role_pattern():
UserCreate(email="a@b.de", role="member") UserCreate(email="a@b.de", role="member")
UserCreate(email="a@b.de", role="org_admin") UserCreate(email="a@b.de", role="org_admin")