Promote develop → main (2026-07-25 23:31 UTC) #53
@@ -1,4 +1,15 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "2026-07-25T23:31Z",
|
||||||
|
"date": "2026-07-25",
|
||||||
|
"title": "Abrechnung, Budget-Warnung & Telegram-Verbesserungen",
|
||||||
|
"items": [
|
||||||
|
"Nicht genutzte Credits verfallen am Monatsende und werden nicht mehr in den Folgemonat übertragen.",
|
||||||
|
"Die Budget-Warnung lässt sich durch Setzen der Warnschwelle auf 0 gezielt deaktivieren.",
|
||||||
|
"Telegram-Statusmeldungen zeigen jetzt die zugehörige Telefonnummer an.",
|
||||||
|
"Der Verbindungsstatus der Telegram-Sitzung ist jetzt im Systemstatus einsehbar."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2026-07-23T21:10Z",
|
"version": "2026-07-23T21:10Z",
|
||||||
"date": "2026-07-23",
|
"date": "2026-07-23",
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ einheitlich in Monitor und Verwaltungsportal).
|
|||||||
|
|
||||||
## Was eine Aktion kostet
|
## Was eine Aktion kostet
|
||||||
|
|
||||||
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus
|
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz,
|
||||||
`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich
|
unabhängig davon, was sie uns tatsächlich verursacht hat. 1 Credit entspricht
|
||||||
verursacht hat. 1 Credit entspricht 0,20 USD.
|
0,20 USD. Die wirksamen Sätze stehen seit 25.07.2026 in der geteilten Tabelle
|
||||||
|
`billing_tariff` und sind über den Verbrauchsrechner des Verwaltungsportals
|
||||||
|
pflegbar. `CREDIT_TARIFF` in `src/config.py` befüllt die Tabelle beim ersten
|
||||||
|
Start und bleibt Rückfallebene für fehlende Schlüssel.
|
||||||
|
|
||||||
| Aktion | Credits | Herkunft des Werts |
|
| Aktion | Credits | Herkunft des Werts |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -58,11 +61,9 @@ Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen
|
|||||||
Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff
|
Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff
|
||||||
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
|
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
|
||||||
|
|
||||||
`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt
|
Ungenutzte Credits verfallen zum Periodenende. Ein Übertrag in den Folgemonat
|
||||||
auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim
|
war kurz vorgesehen und wurde als Produktentscheidung 07/2026 wieder entfernt,
|
||||||
Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist.
|
es gilt der harte Monatsdeckel wie verkauft.
|
||||||
Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im
|
|
||||||
Quartal, und genau dann darf das System nicht dichtmachen.
|
|
||||||
|
|
||||||
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
|
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
|
||||||
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
|
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
|
||||||
@@ -90,7 +91,6 @@ nächste Schritt.
|
|||||||
| `credits_total` | Kontingent je Periode | 10000 |
|
| `credits_total` | Kontingent je Periode | 10000 |
|
||||||
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
|
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
|
||||||
| `credits_period` | `monthly` oder `total` | monthly |
|
| `credits_period` | `monthly` oder `total` | monthly |
|
||||||
| `credits_rollover` | Übertrag erlauben | 1 |
|
|
||||||
| `budget_warning_percent` | Warnschwelle in Prozent | 80 |
|
| `budget_warning_percent` | Warnschwelle in Prozent | 80 |
|
||||||
| `unlimited_budget` | Kontingent aushebeln | 0 |
|
| `unlimited_budget` | Kontingent aushebeln | 0 |
|
||||||
|
|
||||||
@@ -138,8 +138,6 @@ Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf.
|
|||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- E-Mail-Versand der Budget-Warnung.
|
- E-Mail-Versand der Budget-Warnung.
|
||||||
- Eingabefelder für die Stellwerte im Verwaltungsportal (heute erzeugt das
|
|
||||||
Anlegen einer Organisation dort nicht einmal eine Lizenz).
|
|
||||||
- Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben).
|
- Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben).
|
||||||
- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine
|
- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine
|
||||||
Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur
|
Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur
|
||||||
|
|||||||
36
src/auth.py
36
src/auth.py
@@ -59,6 +59,41 @@ def decode_token(token: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aktivitaets-Erfassung fuer MAU/DAU ---------------------------------------
|
||||||
|
# Je Nutzer und Kalendertag genau ein Eintrag in user_activity_days. Der
|
||||||
|
# In-Memory-Merker haelt die DB-Last bei einem INSERT je Nutzer und Tag.
|
||||||
|
# Ein Fehler hier darf NIEMALS eine Anfrage blockieren.
|
||||||
|
_activity_seen: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _track_activity(user_id: int, tenant_id) -> None:
|
||||||
|
day = datetime.now(TIMEZONE).strftime("%Y-%m-%d")
|
||||||
|
key = (user_id, day)
|
||||||
|
if key in _activity_seen:
|
||||||
|
return
|
||||||
|
_activity_seen.add(key)
|
||||||
|
if len(_activity_seen) > 20000:
|
||||||
|
# Speicher begrenzen, alte Tage interessieren den Merker nicht mehr
|
||||||
|
_activity_seen.clear()
|
||||||
|
_activity_seen.add(key)
|
||||||
|
try:
|
||||||
|
import aiosqlite
|
||||||
|
from config import DB_PATH
|
||||||
|
db = await aiosqlite.connect(DB_PATH)
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_activity_days (user_id, day, tenant_id) VALUES (?, ?, ?)",
|
||||||
|
(user_id, day, tenant_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception:
|
||||||
|
# Bewusst schlucken (z.B. Tabelle fehlt noch). Der Merker behaelt den
|
||||||
|
# Schluessel, damit ein Dauerfehler nicht jede Anfrage erneut trifft.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -69,6 +104,7 @@ async def get_current_user(
|
|||||||
detail="Nicht authentifiziert",
|
detail="Nicht authentifiziert",
|
||||||
)
|
)
|
||||||
payload = decode_token(credentials.credentials)
|
payload = decode_token(credentials.credentials)
|
||||||
|
await _track_activity(int(payload["sub"]), payload.get("tenant_id"))
|
||||||
return {
|
return {
|
||||||
"id": int(payload["sub"]),
|
"id": int(payload["sub"]),
|
||||||
"username": payload["username"],
|
"username": payload["username"],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import aiosqlite
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from config import DB_PATH, DATA_DIR
|
from config import DB_PATH, DATA_DIR, CREDIT_TARIFF
|
||||||
|
|
||||||
logger = logging.getLogger("osint.database")
|
logger = logging.getLogger("osint.database")
|
||||||
|
|
||||||
@@ -877,10 +877,9 @@ async def init_db():
|
|||||||
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
|
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
|
||||||
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
|
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
|
||||||
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
|
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
|
||||||
# Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter
|
# Ungenutzte Credits verfallen zum Periodenende. Ein Uebertrag in den
|
||||||
# Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab.
|
# Folgemonat war kurz vorgesehen und wurde als Produktentscheidung
|
||||||
await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0")
|
# 07/2026 wieder entfernt (harter Monatsdeckel wie verkauft).
|
||||||
await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0")
|
|
||||||
# Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet.
|
# Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet.
|
||||||
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0")
|
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -896,6 +895,64 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
|
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
|
# Migration: Preistabelle fuer die Credits-Saetze. Pflegbare Quelle der
|
||||||
|
# Wahrheit fuer Monitor UND Verwaltungsportal (das Portal schreibt sie
|
||||||
|
# ueber den Verbrauchsrechner). Beim ersten Start mit den wirksamen
|
||||||
|
# Konfigurationswerten befuellt, danach gewinnt die Tabelle,
|
||||||
|
# CREDIT_TARIFF bleibt Rueckfallebene fuer fehlende Schluessel.
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='billing_tariff'"
|
||||||
|
)
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE billing_tariff (
|
||||||
|
tariff_key TEXT PRIMARY KEY,
|
||||||
|
credits REAL NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
for tariff_key, credits in CREDIT_TARIFF.items():
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO billing_tariff (tariff_key, credits) VALUES (?, ?)",
|
||||||
|
(tariff_key, float(credits)),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: billing_tariff angelegt und mit Konfigurationswerten befuellt")
|
||||||
|
|
||||||
|
# Migration: Aktivitaets-Tage je Nutzer fuer die MAU/DAU-Statistik im
|
||||||
|
# Verwaltungsportal. Geschrieben von auth.get_current_user (ein Eintrag
|
||||||
|
# je Nutzer und Tag), gelesen nur vom Portal.
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_activity_days'"
|
||||||
|
)
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE user_activity_days (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
tenant_id INTEGER,
|
||||||
|
PRIMARY KEY (user_id, day)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: user_activity_days angelegt (MAU/DAU-Erfassung)")
|
||||||
|
|
||||||
|
# Migration: System-Status (Key-Value). Der Monitor meldet hier z.B. den
|
||||||
|
# Telegram-Session-Status, das Verwaltungsportal liest ihn nur an.
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='system_status'"
|
||||||
|
)
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE system_status (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: system_status angelegt (u.a. Telegram-Session-Status)")
|
||||||
|
|
||||||
# Migration: Token-Usage-Monatstabelle
|
# Migration: Token-Usage-Monatstabelle
|
||||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
||||||
if not await cursor.fetchone():
|
if not await cursor.fetchone():
|
||||||
|
|||||||
40
src/main.py
40
src/main.py
@@ -222,6 +222,42 @@ async def daily_source_health_check():
|
|||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_telegram_status():
|
||||||
|
"""Prüft die Telegram-Session und meldet den Status in system_status.
|
||||||
|
|
||||||
|
Das Verwaltungsportal zeigt den Eintrag im Reiter Recherche-Zugänge an.
|
||||||
|
Nutzt den eigenen Telethon-Client des Prozesses, ein Fehler darf den
|
||||||
|
Betrieb nie stören (nur Status + Log).
|
||||||
|
"""
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
status = {"ok": False, "account": None, "error": None}
|
||||||
|
try:
|
||||||
|
from feeds.telegram_parser import TelegramParser
|
||||||
|
client = await TelegramParser()._get_client()
|
||||||
|
if client:
|
||||||
|
me = await client.get_me()
|
||||||
|
status["ok"] = True
|
||||||
|
status["account"] = ((me.first_name or "").strip() or None) if me else None
|
||||||
|
status["phone"] = ("+" + me.phone) if me and me.phone else None
|
||||||
|
else:
|
||||||
|
status["error"] = "Session fehlt oder nicht autorisiert"
|
||||||
|
except Exception as e:
|
||||||
|
status["error"] = str(e)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO system_status (key, value, updated_at) "
|
||||||
|
"VALUES ('telegram_session', ?, CURRENT_TIMESTAMP) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
(json.dumps(status),),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Telegram-Status gemeldet: ok={status['ok']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram-Status-Update fehlgeschlagen: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
async def cleanup_expired():
|
async def cleanup_expired():
|
||||||
"""Bereinigt abgelaufene Lagen basierend auf retention_days."""
|
"""Bereinigt abgelaufene Lagen basierend auf retention_days."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
@@ -344,8 +380,12 @@ async def lifespan(app: FastAPI):
|
|||||||
scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh")
|
scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh")
|
||||||
scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup")
|
scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup")
|
||||||
scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health")
|
scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health")
|
||||||
|
scheduler.add_job(update_telegram_status, "cron", hour=4, minute=30, id="telegram_status")
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
# Telegram-Status einmal beim Start melden (asynchron, blockiert den Start nicht)
|
||||||
|
asyncio.create_task(update_telegram_status())
|
||||||
|
|
||||||
logger.info("OSINT Lagemonitor gestartet")
|
logger.info("OSINT Lagemonitor gestartet")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
|
|||||||
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
|
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
|
||||||
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
|
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
|
||||||
|
|
||||||
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode,
|
Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
|
||||||
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst.
|
Produktentscheidung 07/2026).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
|
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
|
||||||
@@ -70,33 +70,21 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
|
|||||||
if started == period:
|
if started == period:
|
||||||
return lic
|
return lic
|
||||||
|
|
||||||
total = lic.get("credits_total") or 0
|
|
||||||
carried_old = lic.get("credits_carried") or 0
|
|
||||||
used = lic.get("credits_used") or 0
|
|
||||||
|
|
||||||
if lic.get("credits_rollover"):
|
|
||||||
carried_new = max(0.0, (total + carried_old) - used)
|
|
||||||
carried_new = min(carried_new, float(total)) # hoechstens ein Monat
|
|
||||||
else:
|
|
||||||
carried_new = 0.0
|
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE licenses
|
"""UPDATE licenses
|
||||||
SET credits_used = 0, credits_carried = ?, credits_period_start = ?,
|
SET credits_used = 0, credits_period_start = ?,
|
||||||
budget_warning_sent = 0
|
budget_warning_sent = 0
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
(round(carried_new, 2), period, lic["id"]),
|
(period, lic["id"]),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
lic["credits_used"] = 0
|
lic["credits_used"] = 0
|
||||||
lic["credits_carried"] = carried_new
|
|
||||||
lic["credits_period_start"] = period
|
lic["credits_period_start"] = period
|
||||||
lic["budget_warning_sent"] = 0
|
lic["budget_warning_sent"] = 0
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt "
|
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt"
|
||||||
f"(Uebertrag {round(carried_new, 2)} Einheiten)"
|
|
||||||
)
|
)
|
||||||
return lic
|
return lic
|
||||||
|
|
||||||
@@ -185,10 +173,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
|
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
|
||||||
credits_total = lic_dict.get("credits_total")
|
credits_total = lic_dict.get("credits_total")
|
||||||
credits_used = lic_dict.get("credits_used") or 0
|
credits_used = lic_dict.get("credits_used") or 0
|
||||||
credits_carried = lic_dict.get("credits_carried") or 0
|
|
||||||
credits_period = lic_dict.get("credits_period") or "monthly"
|
credits_period = lic_dict.get("credits_period") or "monthly"
|
||||||
# Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat.
|
# Verfuegbar ist genau das Kontingent. Ungenutzte Credits verfallen zum
|
||||||
credits_available = (credits_total or 0) + credits_carried
|
# Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
|
||||||
|
credits_available = credits_total or 0
|
||||||
|
|
||||||
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
|
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
|
||||||
if _staging_mode():
|
if _staging_mode():
|
||||||
@@ -219,7 +207,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
|
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
|
||||||
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
|
|
||||||
budget_exceeded = False
|
budget_exceeded = False
|
||||||
if not unlimited_budget and credits_available > 0:
|
if not unlimited_budget and credits_available > 0:
|
||||||
if credits_used >= credits_available:
|
if credits_used >= credits_available:
|
||||||
@@ -245,7 +232,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"unlimited_budget": False,
|
"unlimited_budget": False,
|
||||||
"credits_total": credits_total,
|
"credits_total": credits_total,
|
||||||
"credits_used": credits_used,
|
"credits_used": credits_used,
|
||||||
"credits_carried": credits_carried,
|
|
||||||
"credits_available": credits_available,
|
"credits_available": credits_available,
|
||||||
"credits_period": credits_period,
|
"credits_period": credits_period,
|
||||||
}
|
}
|
||||||
@@ -262,7 +248,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"unlimited_budget": unlimited_budget,
|
"unlimited_budget": unlimited_budget,
|
||||||
"credits_total": credits_total,
|
"credits_total": credits_total,
|
||||||
"credits_used": credits_used,
|
"credits_used": credits_used,
|
||||||
"credits_carried": credits_carried,
|
|
||||||
"credits_available": credits_available,
|
"credits_available": credits_available,
|
||||||
"credits_period": credits_period,
|
"credits_period": credits_period,
|
||||||
}
|
}
|
||||||
@@ -371,6 +356,24 @@ async def _record_usage_statistics(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_tariff(db: aiosqlite.Connection) -> dict:
|
||||||
|
"""Wirksame Credits-Saetze je Aktion.
|
||||||
|
|
||||||
|
Die Tabelle billing_tariff ist die pflegbare Quelle (das Verwaltungsportal
|
||||||
|
schreibt sie), CREDIT_TARIFF aus der Konfiguration bleibt Rueckfallebene
|
||||||
|
fuer fehlende Schluessel oder eine noch fehlende Tabelle.
|
||||||
|
"""
|
||||||
|
tariff = dict(CREDIT_TARIFF)
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff")
|
||||||
|
for row in await cursor.fetchall():
|
||||||
|
if row["credits"] is not None and row["credits"] > 0:
|
||||||
|
tariff[row["tariff_key"]] = float(row["credits"])
|
||||||
|
except Exception:
|
||||||
|
pass # Tabelle existiert noch nicht, die Konfiguration gilt
|
||||||
|
return tariff
|
||||||
|
|
||||||
|
|
||||||
async def _charge_credits(
|
async def _charge_credits(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
@@ -398,7 +401,8 @@ async def _charge_credits(
|
|||||||
key = _tariff_key(source, incident_type)
|
key = _tariff_key(source, incident_type)
|
||||||
|
|
||||||
if BILLING_MODE == "flat":
|
if BILLING_MODE == "flat":
|
||||||
credits_consumed = CREDIT_TARIFF.get(key)
|
tariff = await _load_tariff(db)
|
||||||
|
credits_consumed = tariff.get(key)
|
||||||
if credits_consumed is None:
|
if credits_consumed is None:
|
||||||
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
|
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
|
||||||
# stillschweigend gratis abzugeben.
|
# stillschweigend gratis abzugeben.
|
||||||
@@ -418,7 +422,7 @@ async def _charge_credits(
|
|||||||
)
|
)
|
||||||
|
|
||||||
used_new = (lic.get("credits_used") or 0) + credits_consumed
|
used_new = (lic.get("credits_used") or 0) + credits_consumed
|
||||||
available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0)
|
available = lic.get("credits_total") or 0
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
|
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
|
||||||
@@ -449,7 +453,12 @@ async def _check_budget_warning(
|
|||||||
if available <= 0 or lic.get("budget_warning_sent"):
|
if available <= 0 or lic.get("budget_warning_sent"):
|
||||||
return
|
return
|
||||||
|
|
||||||
threshold = lic.get("budget_warning_percent") or 80
|
# 0 schaltet die Warnung bewusst aus, NULL faellt auf die Voreinstellung 80.
|
||||||
|
threshold = lic.get("budget_warning_percent")
|
||||||
|
if threshold is None:
|
||||||
|
threshold = 80
|
||||||
|
if threshold <= 0:
|
||||||
|
return
|
||||||
percent = (used / available) * 100
|
percent = (used / available) * 100
|
||||||
if percent < threshold:
|
if percent < threshold:
|
||||||
return
|
return
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren