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