Promote develop → main (2026-07-25 22:22 UTC) #16
67
migrations/2026-07-25_billing_tariff.py
Normale Datei
67
migrations/2026-07-25_billing_tariff.py
Normale Datei
@@ -0,0 +1,67 @@
|
|||||||
|
"""Migration 2026-07-25: Preistabelle billing_tariff anlegen und befüllen.
|
||||||
|
|
||||||
|
Die Tabelle ist die pflegbare Quelle der Credits-Sätze je Aktion. Der Monitor
|
||||||
|
legt sie beim Start selbst an (init_db, mit seinen Konfigurationswerten als
|
||||||
|
Erstbefüllung). Die Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank,
|
||||||
|
auf der kein Monitor läuft, deshalb braucht sie dieses Skript. Idempotent,
|
||||||
|
vorhandene Sätze werden NICHT überschrieben (INSERT OR IGNORE).
|
||||||
|
|
||||||
|
Ausführung:
|
||||||
|
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_billing_tariff.py
|
||||||
|
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_billing_tariff.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Beschlossene Verkaufssätze (23.07.2026), identisch zu den Monitor-Defaults
|
||||||
|
SEED = {
|
||||||
|
"monitor_adhoc": 45.0,
|
||||||
|
"monitor_research": 40.0,
|
||||||
|
"analysis": 12.0,
|
||||||
|
"factcheck": 12.0,
|
||||||
|
"chat": 1.0,
|
||||||
|
"enhance": 1.0,
|
||||||
|
"globe": 1.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}")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS billing_tariff (
|
||||||
|
tariff_key TEXT PRIMARY KEY,
|
||||||
|
credits REAL NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" + billing_tariff angelegt (oder vorhanden)")
|
||||||
|
|
||||||
|
for key, credits in SEED.items():
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO billing_tariff (tariff_key, credits) VALUES (?, ?)",
|
||||||
|
(key, credits),
|
||||||
|
)
|
||||||
|
if cur.rowcount:
|
||||||
|
print(f" + Satz {key} = {credits}")
|
||||||
|
else:
|
||||||
|
print(f" = Satz {key} war bereits gesetzt")
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -12,8 +12,10 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from auth import get_current_admin
|
from auth import get_current_admin
|
||||||
|
from database import get_db
|
||||||
|
from audit import log_action, get_client_ip
|
||||||
|
|
||||||
logger = logging.getLogger("verwaltung.pricing")
|
logger = logging.getLogger("verwaltung.pricing")
|
||||||
router = APIRouter(prefix="/api/pricing", tags=["Pricing"])
|
router = APIRouter(prefix="/api/pricing", tags=["Pricing"])
|
||||||
@@ -21,6 +23,90 @@ router = APIRouter(prefix="/api/pricing", tags=["Pricing"])
|
|||||||
COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db")
|
COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db")
|
||||||
USD_PER_CREDIT = 0.20
|
USD_PER_CREDIT = 0.20
|
||||||
|
|
||||||
|
# Rückfallwerte, falls die Preistabelle (noch) fehlt. Entsprechen den
|
||||||
|
# Monitor-Defaults (CREDIT_TARIFF in AegisSight-Monitor, src/config.py).
|
||||||
|
TARIFF_DEFAULTS = {
|
||||||
|
"monitor_adhoc": 45.0,
|
||||||
|
"monitor_research": 40.0,
|
||||||
|
"analysis": 12.0,
|
||||||
|
"factcheck": 12.0,
|
||||||
|
"chat": 1.0,
|
||||||
|
"enhance": 1.0,
|
||||||
|
"globe": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tariff")
|
||||||
|
async def get_tariff(admin=Depends(get_current_admin)):
|
||||||
|
"""Wirksame Credits-Sätze aus der geteilten Preistabelle billing_tariff.
|
||||||
|
|
||||||
|
Die Tabelle ist die Quelle der Buchung im Monitor. Fehlt sie noch
|
||||||
|
(Monitor-Migration nicht gelaufen), gelten die Rückfallwerte.
|
||||||
|
"""
|
||||||
|
tariff = dict(TARIFF_DEFAULTS)
|
||||||
|
source = "defaults"
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
if rows:
|
||||||
|
source = "db"
|
||||||
|
for row in rows:
|
||||||
|
tariff[row["tariff_key"]] = row["credits"]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("billing_tariff fehlt noch, liefere Rückfallwerte")
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return {"tariff": tariff, "source": source, "usd_per_credit": USD_PER_CREDIT}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tariff")
|
||||||
|
async def update_tariff(data: dict, request: Request, admin=Depends(get_current_admin)):
|
||||||
|
"""Setzt Credits-Sätze in billing_tariff. Ab der nächsten Buchung wirksam."""
|
||||||
|
allowed = tuple(TARIFF_DEFAULTS.keys())
|
||||||
|
updates = {}
|
||||||
|
for key, value in (data or {}).items():
|
||||||
|
if key not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unbekannter Tarif-Schlüssel: {key}")
|
||||||
|
try:
|
||||||
|
v = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} muss eine Zahl sein")
|
||||||
|
if not 0.5 <= v <= 10000:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} muss zwischen 0,5 und 10000 liegen")
|
||||||
|
updates[key] = v
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Sätze übergeben")
|
||||||
|
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff")
|
||||||
|
before = {r["tariff_key"]: r["credits"] for r in await cursor.fetchall()}
|
||||||
|
for key, v in updates.items():
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO billing_tariff (tariff_key, credits, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||||||
|
"ON CONFLICT(tariff_key) DO UPDATE SET credits = excluded.credits, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
(key, v),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await log_action(
|
||||||
|
db, admin, get_client_ip(request),
|
||||||
|
action="update", resource_type="tariff", resource_id=0,
|
||||||
|
before=before, after={**before, **updates},
|
||||||
|
)
|
||||||
|
logger.info(f"Preistabelle aktualisiert: {updates}")
|
||||||
|
return {"ok": True, "updated": updates}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Preistabelle nicht beschreibbar ({exc}). Falls sie fehlt, zuerst den Monitor deployen oder migrations/2026-07-25_billing_tariff.py fahren.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cost-basis")
|
@router.get("/cost-basis")
|
||||||
async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):
|
async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p style="margin-top:10px; font-size:12px; color:var(--text-muted); display:flex; align-items:center; gap:6px;">
|
<p style="margin-top:10px; font-size:12px; color:var(--text-muted); display:flex; align-items:center; gap:6px;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
<span>Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit.
|
<span><span id="tariffInfoText">Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit.</span>
|
||||||
<a href="#" onclick="openRechnerTab(); return false;" style="color:var(--accent);">Verbrauchsrechner öffnen</a></span>
|
<a href="#" onclick="openRechnerTab(); return false;" style="color:var(--accent);">Verbrauchsrechner öffnen</a></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1217,7 +1217,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
<script src="/static/js/app.js?v=20260725d"></script>
|
<script src="/static/js/app.js?v=20260725e"></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>
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ const ThemeManager = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wirksame Credits-Sätze aus der Preistabelle, für alle Anzeigetexte
|
||||||
|
let PORTAL_TARIFF = null;
|
||||||
|
async function loadTariffInfo() {
|
||||||
|
try {
|
||||||
|
const data = await API.get("/api/pricing/tariff");
|
||||||
|
PORTAL_TARIFF = data.tariff || null;
|
||||||
|
const el = document.getElementById("tariffInfoText");
|
||||||
|
if (el && PORTAL_TARIFF) {
|
||||||
|
const t = PORTAL_TARIFF;
|
||||||
|
const n = (v) => Number(v).toLocaleString("de-DE");
|
||||||
|
el.textContent = `Zur Einordnung. Ein Live-Lauf kostet ${n(t.monitor_adhoc)} Credits, ` +
|
||||||
|
`das Anlegen einer Recherche ${n(t.monitor_research * 3)} (drei Durchläufe), ` +
|
||||||
|
`jede Vertiefung ${n(t.monitor_research)}, Chat und Beschreibungs-Assistent je ${n(t.chat)}.`;
|
||||||
|
}
|
||||||
|
} catch (e) { /* statischer Text bleibt als Rückfallebene stehen */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Sprung zum (nachgeladenen) Verbrauchsrechner-Tab
|
// Sprung zum (nachgeladenen) Verbrauchsrechner-Tab
|
||||||
function openRechnerTab() {
|
function openRechnerTab() {
|
||||||
const tab = document.querySelector('.nav-tab[data-section="rechner"]');
|
const tab = document.querySelector('.nav-tab[data-section="rechner"]');
|
||||||
@@ -115,6 +132,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
setupForms();
|
setupForms();
|
||||||
setupTranslation();
|
setupTranslation();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
loadTariffInfo();
|
||||||
loadDashboardTokenStats();
|
loadDashboardTokenStats();
|
||||||
loadTranslationStatus();
|
loadTranslationStatus();
|
||||||
loadOrgs();
|
loadOrgs();
|
||||||
@@ -1289,6 +1307,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
||||||
(function () {
|
(function () {
|
||||||
var s = document.createElement("script");
|
var s = document.createElement("script");
|
||||||
s.src = "/static/js/rechner.js?v=20260725a";
|
s.src = "/static/js/rechner.js?v=20260725b";
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -157,6 +157,79 @@
|
|||||||
|
|
||||||
var empfDaten = null;
|
var empfDaten = null;
|
||||||
|
|
||||||
|
// ---------- Aktive Preise (Tabelle billing_tariff) ----------
|
||||||
|
|
||||||
|
var aktivTarif = null; // zuletzt geladene bzw. gespeicherte aktive Saetze
|
||||||
|
|
||||||
|
function renderAktivHint() {
|
||||||
|
var el = document.getElementById("vr-aktiv-hint");
|
||||||
|
if (!el) return;
|
||||||
|
if (aktivTarif) {
|
||||||
|
el.innerHTML = "<strong>Simulation.</strong> Abgerechnet wird nach der Preistabelle, aktuell <b>" +
|
||||||
|
aktivTarif.live + "</b> je Live-Lauf, <b>" + aktivTarif.research + "</b> je Recherche-Durchlauf, <b>" +
|
||||||
|
aktivTarif.neben + "</b> je Nebenkosten-Aufruf. Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setzeSlider(satz, wert) {
|
||||||
|
var slider = document.getElementById("vr-slider-" + satz);
|
||||||
|
if (slider) {
|
||||||
|
if (wert > parseInt(slider.max, 10)) slider.max = wert;
|
||||||
|
slider.value = wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeTarif() {
|
||||||
|
if (typeof API === "undefined") return;
|
||||||
|
API.get("/api/pricing/tariff").then(function (data) {
|
||||||
|
var t = (data && data.tariff) || {};
|
||||||
|
aktivTarif = {
|
||||||
|
live: Math.round(t.monitor_adhoc || saetze.live),
|
||||||
|
research: Math.round(t.monitor_research || saetze.research),
|
||||||
|
neben: Math.round(t.chat || saetze.neben),
|
||||||
|
};
|
||||||
|
// Regler starten auf den aktiven Preisen
|
||||||
|
saetze.live = aktivTarif.live;
|
||||||
|
saetze.research = aktivTarif.research;
|
||||||
|
saetze.neben = aktivTarif.neben;
|
||||||
|
setzeSlider("live", saetze.live);
|
||||||
|
setzeSlider("research", saetze.research);
|
||||||
|
setzeSlider("neben", saetze.neben);
|
||||||
|
satzGesetzt();
|
||||||
|
renderAktivHint();
|
||||||
|
}).catch(function () { /* Hinweis bleibt generisch */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichereTarif() {
|
||||||
|
var msg = document.getElementById("vr-tarif-msg");
|
||||||
|
var tuEs = function () {
|
||||||
|
if (msg) msg.textContent = "Speichern …";
|
||||||
|
API.put("/api/pricing/tariff", {
|
||||||
|
monitor_adhoc: saetze.live,
|
||||||
|
monitor_research: saetze.research,
|
||||||
|
chat: saetze.neben,
|
||||||
|
enhance: saetze.neben,
|
||||||
|
globe: saetze.neben,
|
||||||
|
}).then(function () {
|
||||||
|
aktivTarif = { live: saetze.live, research: saetze.research, neben: saetze.neben };
|
||||||
|
renderAktivHint();
|
||||||
|
if (typeof loadTariffInfo === "function") loadTariffInfo();
|
||||||
|
if (msg) msg.textContent = "Gespeichert, gilt ab sofort für alle neuen Buchungen.";
|
||||||
|
setTimeout(function () { if (msg) msg.textContent = ""; }, 6000);
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (msg) msg.textContent = "Fehler: " + err.message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (typeof showConfirm === "function") {
|
||||||
|
showConfirm(
|
||||||
|
"Aktive Preise ändern?",
|
||||||
|
"Live-Lauf " + saetze.live + ", Recherche-Durchlauf " + saetze.research +
|
||||||
|
", Nebenkosten " + saetze.neben + " Credits gelten dann ab sofort für alle neuen Buchungen.",
|
||||||
|
tuEs
|
||||||
|
);
|
||||||
|
} else tuEs();
|
||||||
|
}
|
||||||
|
|
||||||
function renderEmpfehlung() {
|
function renderEmpfehlung() {
|
||||||
if (!empfDaten) return;
|
if (!empfDaten) return;
|
||||||
["live", "research"].forEach(function (satz) {
|
["live", "research"].forEach(function (satz) {
|
||||||
@@ -344,13 +417,17 @@
|
|||||||
return '<div class="vr-kopf">' +
|
return '<div class="vr-kopf">' +
|
||||||
"<h2>Verbrauchsrechner</h2>" +
|
"<h2>Verbrauchsrechner</h2>" +
|
||||||
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
|
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
|
||||||
'<p class="vr-basis"><strong>Reine Simulation.</strong> Die Regler ändern keine Sätze im Monitor, abgerechnet wird nach der zentralen Konfiguration (aktuell 45 je Live-Lauf, 40 je Recherche-Durchlauf, Nebenkosten 1).</p>' +
|
'<p class="vr-basis" id="vr-aktiv-hint"><strong>Simulation.</strong> Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.</p>' +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<div class="vr-regler-grid">' +
|
'<div class="vr-regler-grid">' +
|
||||||
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
|
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
|
||||||
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
|
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
|
||||||
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
|
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
|
'<div style="margin:14px 0 4px; display:flex; align-items:center; gap:10px;">' +
|
||||||
|
'<button type="button" class="btn btn-primary" id="vr-tarif-speichern">Diese Sätze als aktive Preise speichern</button>' +
|
||||||
|
'<span id="vr-tarif-msg" style="font-size:13px; color:var(--text-secondary);"></span>' +
|
||||||
|
"</div>" +
|
||||||
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
|
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
|
||||||
'<div class="vr-calc">' +
|
'<div class="vr-calc">' +
|
||||||
"<div>" +
|
"<div>" +
|
||||||
@@ -473,12 +550,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var speichernBtn = document.getElementById("vr-tarif-speichern");
|
||||||
|
if (speichernBtn) speichernBtn.addEventListener("click", speichereTarif);
|
||||||
|
|
||||||
updateWerte();
|
updateWerte();
|
||||||
renderBlock("live");
|
renderBlock("live");
|
||||||
renderBlock("recherche");
|
renderBlock("recherche");
|
||||||
renderBlock("neben");
|
renderBlock("neben");
|
||||||
calc();
|
calc();
|
||||||
ladeEmpfehlung();
|
ladeEmpfehlung();
|
||||||
|
ladeTarif();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren