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:
@@ -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)
|
||||||
|
|||||||
57
migrations/2026-07-25_credits_period.py
Normale Datei
57
migrations/2026-07-25_credits_period.py
Normale Datei
@@ -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))
|
||||||
20
src/main.py
20
src/main.py
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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> — 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren