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
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from auth import get_current_admin
|
||||
from database import get_db
|
||||
from audit import log_action, get_client_ip
|
||||
|
||||
logger = logging.getLogger("verwaltung.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")
|
||||
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")
|
||||
async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
@@ -1217,7 +1217,7 @@
|
||||
</div>
|
||||
|
||||
<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/x-scraper.js?v=20260522a"></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
|
||||
function openRechnerTab() {
|
||||
const tab = document.querySelector('.nav-tab[data-section="rechner"]');
|
||||
@@ -115,6 +132,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupForms();
|
||||
setupTranslation();
|
||||
loadDashboard();
|
||||
loadTariffInfo();
|
||||
loadDashboardTokenStats();
|
||||
loadTranslationStatus();
|
||||
loadOrgs();
|
||||
@@ -1289,6 +1307,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
||||
(function () {
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -157,6 +157,79 @@
|
||||
|
||||
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() {
|
||||
if (!empfDaten) return;
|
||||
["live", "research"].forEach(function (satz) {
|
||||
@@ -344,13 +417,17 @@
|
||||
return '<div class="vr-kopf">' +
|
||||
"<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 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 class="vr-regler-grid">' +
|
||||
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
|
||||
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
|
||||
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
|
||||
"</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>' +
|
||||
'<div class="vr-calc">' +
|
||||
"<div>" +
|
||||
@@ -473,12 +550,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
var speichernBtn = document.getElementById("vr-tarif-speichern");
|
||||
if (speichernBtn) speichernBtn.addEventListener("click", speichereTarif);
|
||||
|
||||
updateWerte();
|
||||
renderBlock("live");
|
||||
renderBlock("recherche");
|
||||
renderBlock("neben");
|
||||
calc();
|
||||
ladeEmpfehlung();
|
||||
ladeTarif();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren