- Neuer Helper charge_usage_to_tenant() in services/license_service.py: UPSERT in token_usage_monthly und Credits-Abzug aus licenses.credits_used. Wiederverwendbar fuer alle Claude-Call-Verursacher. - Orchestrator: Inline-Buchungslogik (35 Zeilen) durch Helper-Aufruf ersetzt. - routers/incidents.py POST /enhance-description: require_writable_license statt get_current_user, db_dependency hinzugefuegt, Credits-Buchung mit source="enhance" nach jedem Claude-Call. - routers/chat.py POST /: analog require_writable_license + Credits-Buchung mit source="chat". _call_claude_chat() gibt jetzt zusaetzlich ClaudeUsage zurueck. Abgelaufene/gesperrte Lizenzen koennen damit keine Haiku-Calls mehr ausloesen, und alle Kosten werden konsistent auf Tenant-Ebene verbucht.
204 Zeilen
7.1 KiB
Python
204 Zeilen
7.1 KiB
Python
"""Lizenz-Verwaltung und -Pruefung."""
|
|
import logging
|
|
from datetime import datetime
|
|
from config import TIMEZONE
|
|
import aiosqlite
|
|
|
|
logger = logging.getLogger("osint.license")
|
|
|
|
|
|
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|
"""Prueft den Lizenzstatus einer Organisation.
|
|
|
|
Returns:
|
|
dict mit: valid, status, license_type, max_users, current_users, read_only, message
|
|
"""
|
|
# Organisation pruefen
|
|
cursor = await db.execute(
|
|
"SELECT id, name, is_active FROM organizations WHERE id = ?",
|
|
(organization_id,),
|
|
)
|
|
org = await cursor.fetchone()
|
|
if not org:
|
|
return {"valid": False, "status": "not_found", "read_only": True, "message": "Organisation nicht gefunden"}
|
|
|
|
if not org["is_active"]:
|
|
return {"valid": False, "status": "org_disabled", "read_only": True, "message": "Organisation deaktiviert"}
|
|
|
|
# Aktive Lizenz suchen
|
|
cursor = await db.execute(
|
|
"""SELECT * FROM licenses
|
|
WHERE organization_id = ? AND status = 'active'
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(organization_id,),
|
|
)
|
|
license_row = await cursor.fetchone()
|
|
|
|
if not license_row:
|
|
return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"}
|
|
|
|
# Ablauf pruefen
|
|
now = datetime.now(TIMEZONE)
|
|
valid_until = license_row["valid_until"]
|
|
|
|
if valid_until is not None:
|
|
try:
|
|
expiry = datetime.fromisoformat(valid_until)
|
|
if expiry.tzinfo is None:
|
|
expiry = expiry.replace(tzinfo=TIMEZONE)
|
|
if now > expiry:
|
|
return {
|
|
"valid": False,
|
|
"status": "expired",
|
|
"license_type": license_row["license_type"],
|
|
"read_only": True,
|
|
"message": "Lizenz abgelaufen",
|
|
}
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Nutzerzahl pruefen
|
|
cursor = await db.execute(
|
|
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
|
|
(organization_id,),
|
|
)
|
|
current_users = (await cursor.fetchone())["cnt"]
|
|
|
|
return {
|
|
"valid": True,
|
|
"status": license_row["status"],
|
|
"license_type": license_row["license_type"],
|
|
"max_users": license_row["max_users"],
|
|
"current_users": current_users,
|
|
"read_only": False,
|
|
"message": "Lizenz aktiv",
|
|
}
|
|
|
|
|
|
async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[bool, str]:
|
|
"""Prueft ob ein neuer Nutzer hinzugefuegt werden kann (Nutzer-Limit).
|
|
|
|
Returns:
|
|
(erlaubt, grund)
|
|
"""
|
|
lic = await check_license(db, organization_id)
|
|
if not lic["valid"]:
|
|
return False, lic["message"]
|
|
|
|
if lic["current_users"] >= lic["max_users"]:
|
|
return False, f"Nutzer-Limit erreicht ({lic['current_users']}/{lic['max_users']})"
|
|
|
|
return True, ""
|
|
|
|
|
|
async def charge_usage_to_tenant(
|
|
db: aiosqlite.Connection,
|
|
tenant_id: int | None,
|
|
usage,
|
|
source: str,
|
|
) -> None:
|
|
"""Verbucht Token-Verbrauch auf einen Tenant.
|
|
|
|
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source)
|
|
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt).
|
|
|
|
Args:
|
|
db: offene aiosqlite.Connection
|
|
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
|
|
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
|
|
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
|
|
source: 'monitor' | 'enhance' | 'chat'
|
|
|
|
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
|
|
Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht.
|
|
"""
|
|
total_cost = getattr(usage, "total_cost_usd", None)
|
|
if total_cost is None:
|
|
total_cost = getattr(usage, "cost_usd", 0.0)
|
|
|
|
if not tenant_id:
|
|
logger.info(
|
|
f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen "
|
|
f"(cost=${total_cost:.4f})"
|
|
)
|
|
return
|
|
|
|
if total_cost <= 0:
|
|
return
|
|
|
|
input_tokens = getattr(usage, "input_tokens", 0)
|
|
output_tokens = getattr(usage, "output_tokens", 0)
|
|
cache_creation = getattr(usage, "cache_creation_tokens", 0)
|
|
cache_read = getattr(usage, "cache_read_tokens", 0)
|
|
api_calls = getattr(usage, "call_count", 1)
|
|
refresh_increment = 1 if source == "monitor" else 0
|
|
|
|
year_month = datetime.now(TIMEZONE).strftime("%Y-%m")
|
|
|
|
await db.execute(
|
|
"""
|
|
INSERT INTO token_usage_monthly
|
|
(organization_id, year_month, source, input_tokens, output_tokens,
|
|
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(organization_id, year_month, source) DO UPDATE SET
|
|
input_tokens = input_tokens + excluded.input_tokens,
|
|
output_tokens = output_tokens + excluded.output_tokens,
|
|
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
|
|
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
|
|
api_calls = api_calls + excluded.api_calls,
|
|
refresh_count = refresh_count + excluded.refresh_count,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
tenant_id, year_month, source,
|
|
input_tokens, output_tokens, cache_creation, cache_read,
|
|
round(total_cost, 7), api_calls, refresh_increment,
|
|
),
|
|
)
|
|
|
|
lic_cursor = await db.execute(
|
|
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
|
(tenant_id,),
|
|
)
|
|
lic = await lic_cursor.fetchone()
|
|
credits_consumed = 0.0
|
|
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
|
|
credits_consumed = total_cost / lic["cost_per_credit"]
|
|
await db.execute(
|
|
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
|
|
(round(credits_consumed, 2), tenant_id),
|
|
)
|
|
|
|
logger.info(
|
|
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: "
|
|
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits"
|
|
)
|
|
|
|
|
|
async def expire_licenses(db: aiosqlite.Connection):
|
|
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
|
cursor = await db.execute(
|
|
"""SELECT id, organization_id FROM licenses
|
|
WHERE status = 'active'
|
|
AND valid_until IS NOT NULL
|
|
AND valid_until < datetime('now')"""
|
|
)
|
|
expired = await cursor.fetchall()
|
|
|
|
count = 0
|
|
for lic in expired:
|
|
await db.execute(
|
|
"UPDATE licenses SET status = 'expired' WHERE id = ?",
|
|
(lic["id"],),
|
|
)
|
|
count += 1
|
|
logger.info(f"Lizenz {lic['id']} fuer Org {lic['organization_id']} als abgelaufen markiert")
|
|
|
|
if count > 0:
|
|
await db.commit()
|
|
logger.info(f"{count} Lizenz(en) als abgelaufen markiert")
|
|
|
|
return count
|