Promote develop → main (2026-07-25 23:31 UTC) #53

Zusammengeführt
IntelSight_Admin hat 7 Commits von develop nach main 2026-07-26 01:31:20 +02:00 zusammengeführt
6 geänderte Dateien mit 193 neuen und 42 gelöschten Zeilen

Datei anzeigen

@@ -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",
"date": "2026-07-23",

Datei anzeigen

@@ -15,9 +15,12 @@ einheitlich in Monitor und Verwaltungsportal).
## Was eine Aktion kostet
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus
`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich
verursacht hat. 1 Credit entspricht 0,20 USD.
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz,
unabhängig davon, was sie uns tatsächlich verursacht hat. 1 Credit entspricht
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 |
|---|---|---|
@@ -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
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt
auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim
Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist.
Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im
Quartal, und genau dann darf das System nicht dichtmachen.
Ungenutzte Credits verfallen zum Periodenende. Ein Übertrag in den Folgemonat
war kurz vorgesehen und wurde als Produktentscheidung 07/2026 wieder entfernt,
es gilt der harte Monatsdeckel wie verkauft.
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
@@ -90,7 +91,6 @@ nächste Schritt.
| `credits_total` | Kontingent je Periode | 10000 |
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
| `credits_period` | `monthly` oder `total` | monthly |
| `credits_rollover` | Übertrag erlauben | 1 |
| `budget_warning_percent` | Warnschwelle in Prozent | 80 |
| `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
- 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).
- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine
Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur

Datei anzeigen

@@ -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(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
@@ -69,6 +104,7 @@ async def get_current_user(
detail="Nicht authentifiziert",
)
payload = decode_token(credentials.credentials)
await _track_activity(int(payload["sub"]), payload.get("tenant_id"))
return {
"id": int(payload["sub"]),
"username": payload["username"],

Datei anzeigen

@@ -3,7 +3,7 @@ import aiosqlite
import json
import logging
import os
from config import DB_PATH, DATA_DIR
from config import DB_PATH, DATA_DIR, CREDIT_TARIFF
logger = logging.getLogger("osint.database")
@@ -877,10 +877,9 @@ async def init_db():
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
# Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter
# Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0")
await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0")
# Ungenutzte Credits verfallen zum Periodenende. Ein Uebertrag in den
# Folgemonat war kurz vorgesehen und wurde als Produktentscheidung
# 07/2026 wieder entfernt (harter Monatsdeckel wie verkauft).
# 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.commit()
@@ -896,6 +895,64 @@ async def init_db():
await db.commit()
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
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
if not await cursor.fetchone():

Datei anzeigen

@@ -222,6 +222,42 @@ async def daily_source_health_check():
finally:
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():
"""Bereinigt abgelaufene Lagen basierend auf retention_days."""
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(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(update_telegram_status, "cron", hour=4, minute=30, id="telegram_status")
scheduler.start()
# Telegram-Status einmal beim Start melden (asynchron, blockiert den Start nicht)
asyncio.create_task(update_telegram_status())
logger.info("OSINT Lagemonitor gestartet")
yield

Datei anzeigen

@@ -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
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode,
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst.
Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
Produktentscheidung 07/2026).
Returns:
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:
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(
"""UPDATE licenses
SET credits_used = 0, credits_carried = ?, credits_period_start = ?,
SET credits_used = 0, credits_period_start = ?,
budget_warning_sent = 0
WHERE id = ?""",
(round(carried_new, 2), period, lic["id"]),
(period, lic["id"]),
)
await db.commit()
lic["credits_used"] = 0
lic["credits_carried"] = carried_new
lic["credits_period_start"] = period
lic["budget_warning_sent"] = 0
logger.info(
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt "
f"(Uebertrag {round(carried_new, 2)} Einheiten)"
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt"
)
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"))
credits_total = lic_dict.get("credits_total")
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"
# Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat.
credits_available = (credits_total or 0) + credits_carried
# Verfuegbar ist genau das Kontingent. Ungenutzte Credits verfallen zum
# Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
credits_available = credits_total or 0
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode():
@@ -219,7 +207,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
pass
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
budget_exceeded = False
if not unlimited_budget and credits_available > 0:
if credits_used >= credits_available:
@@ -245,7 +232,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"credits_period": credits_period,
}
@@ -262,7 +248,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"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(
db: aiosqlite.Connection,
tenant_id: int,
@@ -398,7 +401,8 @@ async def _charge_credits(
key = _tariff_key(source, incident_type)
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:
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
# stillschweigend gratis abzugeben.
@@ -418,7 +422,7 @@ async def _charge_credits(
)
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(
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"):
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
if percent < threshold:
return