From e0c39de21f3675fc028aab825f991adf4b1a764a Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 13:11:02 +0000 Subject: [PATCH] feat(kontingent): Credits-Stellwerte je Lizenz + Lizenz beim Org-Anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 5 +- migrations/2026-07-25_credits_period.py | 57 +++++++++ src/main.py | 20 ++++ src/models.py | 42 +++++-- src/routers/licenses.py | 148 ++++++++++++++---------- src/routers/organizations.py | 15 +++ src/routers/token_usage.py | 55 ++++++++- src/static/dashboard.html | 128 +++++++++++++++++++- src/static/js/app.js | 134 +++++++++++++++++++-- tests/test_models.py | 54 ++++++++- 10 files changed, 566 insertions(+), 92 deletions(-) create mode 100644 migrations/2026-07-25_credits_period.py diff --git a/CLAUDE.md b/CLAUDE.md index 854ba9c..e8c6515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,11 +46,11 @@ src/: routers/: 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" sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge" 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" email_utils/: @@ -70,6 +70,7 @@ src/: migrations/: 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) diff --git a/migrations/2026-07-25_credits_period.py b/migrations/2026-07-25_credits_period.py new file mode 100644 index 0000000..13d3cdc --- /dev/null +++ b/migrations/2026-07-25_credits_period.py @@ -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)) diff --git a/src/main.py b/src/main.py index e780557..b5e20fb 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung") @asynccontextmanager 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) yield logger.info("Verwaltungsportal beendet") diff --git a/src/models.py b/src/models.py index 573afa2..d362fec 100644 --- a/src/models.py +++ b/src/models.py @@ -22,10 +22,36 @@ class TokenResponse(BaseModel): 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): name: str = Field(min_length=1, max_length=200) slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") output_language: str = Field(default="de", pattern="^(de|en)$") + # Optional direkt eine Lizenz mit Credits-Kontingent anlegen + license: Optional[LicenseParams] = None class OrgUpdate(BaseModel): @@ -48,16 +74,8 @@ class OrgResponse(BaseModel): output_language: str = "de" -class LicenseCreate(BaseModel): +class LicenseCreate(LicenseParams): 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): @@ -75,6 +93,12 @@ class LicenseResponse(BaseModel): cost_per_credit: Optional[float] = None budget_warning_percent: Optional[int] = None 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 globe_access: bool = False network_access: bool = False diff --git a/src/routers/licenses.py b/src/routers/licenses.py index c83d427..05398a8 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -1,7 +1,7 @@ """Lizenz-CRUD.""" from datetime import datetime, timedelta, timezone 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 database import db_dependency from audit import log_action, get_client_ip, row_to_dict @@ -10,6 +10,90 @@ import aiosqlite 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]) async def list_licenses( org_id: int = None, @@ -39,68 +123,12 @@ async def create_license( if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Organisation nicht gefunden") - # 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( - "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 + lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data) await db.commit() - cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,)) - 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 await audit_license_creation( + db, admin, get_client_ip(request), lic_id, revoked_lics ) - return new_lic @router.put("/{license_id}/revoke") diff --git a/src/routers/organizations.py b/src/routers/organizations.py index a936665..28f38e7 100644 --- a/src/routers/organizations.py +++ b/src/routers/organizations.py @@ -64,6 +64,16 @@ async def create_organization( (data.name, data.slug, now, now), ) 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() # output_language als organization_settings-Eintrag persistieren @@ -77,6 +87,11 @@ async def create_organization( action="create", resource_type="organization", resource_id=org_id, 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) diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py index af02c81..e14faf2 100644 --- a/src/routers/token_usage.py +++ b/src/routers/token_usage.py @@ -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") diff --git a/src/static/dashboard.html b/src/static/dashboard.html index b1fe1e8..30d8f30 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -267,18 +267,35 @@
- + +
+
+ + +
+
+ +
+
+ +
- +
- +
@@ -288,6 +305,31 @@
+ +

Laufzeitwerte

+
+
+

+ 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. +

+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+
+
@@ -574,6 +616,63 @@ Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor. + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
- + +
+
+ + +
+
+ +
+
+ +
- +
@@ -1038,7 +1154,7 @@
- + diff --git a/src/static/js/app.js b/src/static/js/app.js index cf47dab..9c047be 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -442,14 +442,42 @@ function setupForms() { e.preventDefault(); const errEl = document.getElementById("newOrgError"); 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 { - await API.post("/api/orgs", { - name: document.getElementById("newOrgName").value, - slug: document.getElementById("newOrgSlug").value, - output_language: document.getElementById("newOrgLanguage").value || "de", - }); + await API.post("/api/orgs", body); closeModal("modalNewOrg"); document.getElementById("newOrgForm").reset(); + onNewOrgLicenseToggle(); loadOrgs(); loadDashboard(); } catch (err) { @@ -515,6 +543,10 @@ function setupForms() { } const unlimitedEl = document.getElementById('newLicUnlimited'); 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) { const creditsTotal = document.getElementById('newLicCreditsTotal'); const costPerCredit = document.getElementById('newLicCostPerCredit'); @@ -523,6 +555,8 @@ function setupForms() { if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.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 { await API.post("/api/licenses", body); closeModal("modalNewLicense"); @@ -818,7 +852,11 @@ async function loadOrgTokenUsage(orgId) { el('tokenCreditsRemaining', '∞ Unbegrenzt'); el('tokenBudgetUsd', '—'); } 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('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('editBudgetUsd', budget.token_budget_usd); 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'); if (cb) { cb.checked = !!budget.unlimited_budget; 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 function onUnlimitedToggle() { const cb = document.getElementById('editUnlimitedBudget'); 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); if (e) { e.disabled = isUnlimited; 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 function onNewLicUnlimitedToggle() { const cb = document.getElementById('newLicUnlimited'); 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); if (e) { e.disabled = isUnlimited; 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() { @@ -977,6 +1087,14 @@ document.addEventListener('DOMContentLoaded', function() { if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.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) if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value); diff --git a/tests/test_models.py b/tests/test_models.py index e42e58a..6c7006f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,7 @@ from pydantic import ValidationError from models import ( MagicLinkRequest, MagicLinkResponse, 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") +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(): UserCreate(email="a@b.de", role="member") UserCreate(email="a@b.de", role="org_admin")