feat(abrechnung): Credits-Uebertrag komplett entfernt
Produktentscheidung 07/2026: ungenutzte Credits verfallen zum Periodenende. Uebertrag-Checkbox aus allen drei Formularen (Kontingent bearbeiten, Lizenz anlegen, Org anlegen) samt Ausgrau-Logik raus, credits_rollover/credits_carried aus Modellen, Endpoints (overview/current/PUT budget) und insert_license_row raus, Anzeige inkl. Uebertrag im Org-Detail raus. Migrationsskript legt die beiden Spalten nicht mehr an. Gegenstueck zum Monitor-Commit f8499c4, Promote-Reihenfolge wie immer Monitor VOR Portal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -47,11 +47,11 @@ src/:
|
||||
|
||||
routers/:
|
||||
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)"
|
||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode; Helper insert_license_row wird auch vom Org-Anlegen genutzt). Kein Credits-Übertrag mehr (Produktentscheidung 07/2026, ungenutzte Credits verfallen)"
|
||||
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
|
||||
sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation"
|
||||
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
|
||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
|
||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
|
||||
pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern"
|
||||
statistik.py: "Betriebsstatistik (GET /api/statistik?months=N). Kosten/Marge je Monat und Org, Betrieb, Fehler-Ranking, MAU/DAU aus user_activity_days, Wachstum. Aggregation als pure Funktionen (unit-testbar)"
|
||||
audit.py: "Audit-Log-Abfrage, Filter (action/resource_type/admin_id akzeptieren kommagetrennte Mehrfachwerte)"
|
||||
|
||||
@@ -17,12 +17,12 @@ import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration
|
||||
# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration.
|
||||
# credits_rollover/credits_carried gab es hier kurzzeitig, der Uebertrag
|
||||
# wurde als Produktentscheidung 07/2026 entfernt (verfaellt zum Periodenende).
|
||||
COLUMNS = {
|
||||
"credits_period": "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'",
|
||||
"credits_period_start": "ALTER TABLE licenses ADD COLUMN credits_period_start TEXT",
|
||||
"credits_rollover": "ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0",
|
||||
"credits_carried": "ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0",
|
||||
"budget_warning_sent": "ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0",
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ class LicenseParams(BaseModel):
|
||||
|
||||
Wird zweifach genutzt, direkt beim Lizenz-Anlegen (LicenseCreate) und
|
||||
verschachtelt beim Org-Anlegen (OrgCreate.license). Die Laufzeitfelder
|
||||
(credits_used, credits_carried, credits_period_start, budget_warning_sent)
|
||||
verwaltet der Monitor, das Portal setzt nur Stellwerte.
|
||||
(credits_used, credits_period_start, budget_warning_sent) verwaltet der
|
||||
Monitor, das Portal setzt nur Stellwerte.
|
||||
"""
|
||||
license_type: str = Field(pattern="^(trial|annual|permanent)$")
|
||||
max_users: int = Field(default=5, ge=1, le=1000)
|
||||
@@ -40,11 +40,9 @@ class LicenseParams(BaseModel):
|
||||
unlimited_budget: bool = False
|
||||
# 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt (der Monitor
|
||||
# setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit.
|
||||
# Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
|
||||
# Produktentscheidung 07/2026).
|
||||
credits_period: str = Field(default="monthly", pattern="^(monthly|total)$")
|
||||
# Uebertrag ungenutzter Credits in den Folgemonat, der Monitor deckelt
|
||||
# auf ein Monatskontingent. Standard AUS (Entscheidung Nutzer 25.07.2026),
|
||||
# pro Kunde bewusst zuschaltbar.
|
||||
credits_rollover: bool = False
|
||||
|
||||
|
||||
class OrgCreate(BaseModel):
|
||||
@@ -97,8 +95,6 @@ class LicenseResponse(BaseModel):
|
||||
# Neue Kontingent-Felder, alle optional mit Default, damit Responses auch
|
||||
# von einer noch nicht migrierten Datenbank validieren.
|
||||
credits_period: Optional[str] = "monthly"
|
||||
credits_rollover: Optional[bool] = False
|
||||
credits_carried: Optional[float] = 0
|
||||
credits_period_start: Optional[str] = None
|
||||
created_at: str
|
||||
globe_access: bool = False
|
||||
|
||||
@@ -44,29 +44,27 @@ async def insert_license_row(
|
||||
elif params.license_type == "annual":
|
||||
valid_until = (now + timedelta(days=365)).isoformat()
|
||||
|
||||
# Bei unlimited_budget: Kontingent-Felder ignorieren, Uebertrag aus
|
||||
# Bei unlimited_budget: Kontingent-Felder ignorieren
|
||||
if params.unlimited_budget:
|
||||
token_budget_usd = None
|
||||
credits_total = None
|
||||
cost_per_credit = None
|
||||
credits_rollover = 0
|
||||
else:
|
||||
token_budget_usd = params.token_budget_usd
|
||||
credits_total = params.credits_total
|
||||
cost_per_credit = params.cost_per_credit
|
||||
credits_rollover = 1 if params.credits_rollover else 0
|
||||
|
||||
# credits_period_start bleibt bewusst NULL, der Monitor setzt die
|
||||
# Periodenmarke traege bei der naechsten Lizenzpruefung selbst.
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
|
||||
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget,
|
||||
credits_period, credits_rollover)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)""",
|
||||
credits_period)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)""",
|
||||
(org_id, params.license_type, params.max_users, valid_from, valid_until,
|
||||
token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent,
|
||||
1 if params.unlimited_budget else 0,
|
||||
params.credits_period, credits_rollover),
|
||||
params.credits_period),
|
||||
)
|
||||
return cursor.lastrowid, revoked_lics
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
o.id, o.name, o.slug,
|
||||
l.credits_total, l.credits_used, l.cost_per_credit,
|
||||
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
|
||||
l.credits_period, l.credits_rollover, l.credits_carried, l.credits_period_start,
|
||||
l.credits_period, l.credits_period_start,
|
||||
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
|
||||
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
|
||||
@@ -37,12 +37,11 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
for row in rows:
|
||||
credits_total = row["credits_total"] or 0
|
||||
credits_used = row["credits_used"] or 0
|
||||
credits_carried = row["credits_carried"] or 0
|
||||
unlimited = bool(row["unlimited_budget"])
|
||||
# Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim
|
||||
# Hard-Stop inklusive Uebertrag, die Anzeige muss dieselbe
|
||||
# Bezugsgroesse nutzen, sonst zeigt sie zu wenig Rest an.
|
||||
credits_available = credits_total + credits_carried
|
||||
# Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der
|
||||
# Hard-Stop im Monitor. Ungenutzte Credits verfallen zum
|
||||
# Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
|
||||
credits_available = credits_total
|
||||
credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None)
|
||||
percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None)
|
||||
budget_usd = row["token_budget_usd"]
|
||||
@@ -58,9 +57,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
|
||||
"credits_remaining": credits_remaining,
|
||||
"credits_percent_used": percent_used,
|
||||
"credits_available": credits_available,
|
||||
"credits_carried": credits_carried,
|
||||
"credits_period": row["credits_period"] or "monthly",
|
||||
"credits_rollover": bool(row["credits_rollover"]),
|
||||
"credits_period_start": row["credits_period_start"],
|
||||
"token_budget_usd": budget_usd,
|
||||
"total_cost_usd": round(cost, 2),
|
||||
@@ -134,7 +131,7 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, "
|
||||
"credits_period, credits_rollover, credits_carried, credits_period_start "
|
||||
"credits_period, credits_period_start "
|
||||
"FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||
(org_id,))
|
||||
lic = await cursor.fetchone()
|
||||
@@ -142,10 +139,9 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
unlimited = bool(lic["unlimited_budget"]) if lic else False
|
||||
credits_total = lic["credits_total"] if lic else None
|
||||
credits_used = lic["credits_used"] if lic else 0
|
||||
credits_carried = (lic["credits_carried"] or 0) if lic else 0
|
||||
# Verfuegbar = Kontingent plus Uebertrag, dieselbe Bezugsgroesse wie
|
||||
# der Hard-Stop im Monitor.
|
||||
credits_available = (credits_total or 0) + credits_carried
|
||||
# Verfuegbar = Kontingent, dieselbe Bezugsgroesse wie der Hard-Stop
|
||||
# im Monitor (kein Uebertrag, Produktentscheidung 07/2026).
|
||||
credits_available = credits_total or 0
|
||||
|
||||
return {
|
||||
"year_month": year_month,
|
||||
@@ -164,9 +160,7 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
||||
"credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None),
|
||||
"credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None),
|
||||
"credits_available": credits_available,
|
||||
"credits_carried": credits_carried,
|
||||
"credits_period": (lic["credits_period"] or "monthly") if lic else "monthly",
|
||||
"credits_rollover": bool(lic["credits_rollover"]) if lic else False,
|
||||
"credits_period_start": lic["credits_period_start"] if lic else None,
|
||||
"token_budget_usd": lic["token_budget_usd"] if lic else None,
|
||||
"cost_per_credit": lic["cost_per_credit"] if lic else None,
|
||||
@@ -223,12 +217,8 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
|
||||
fields.append("unlimited_budget = ?")
|
||||
values.append(1 if data["unlimited_budget"] else 0)
|
||||
|
||||
if "credits_rollover" in data:
|
||||
fields.append("credits_rollover = ?")
|
||||
values.append(1 if data["credits_rollover"] else 0)
|
||||
|
||||
# Bewusst NICHT aenderbar: credits_carried, credits_period_start und
|
||||
# budget_warning_sent sind Laufzeitfelder, die verwaltet der Monitor.
|
||||
# Bewusst NICHT aenderbar: credits_period_start und budget_warning_sent
|
||||
# sind Laufzeitfelder, die verwaltet der Monitor.
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
@@ -76,6 +76,32 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artikel-Übersetzung -->
|
||||
<div class="card" id="translationCard" style="margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<h2>Artikel-Übersetzung</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="margin-top:0;">
|
||||
Die automatische Übersetzung im Monitor ist deaktiviert. Hier lassen sich
|
||||
fremdsprachige Artikel ohne deutsche Fassung manuell übersetzen.
|
||||
</p>
|
||||
<p id="translationInfo" style="margin:12px 0;">Status wird geladen…</p>
|
||||
|
||||
<div id="translationProgressWrap" style="display:none; margin:12px 0;">
|
||||
<div style="background:rgba(128,128,128,0.25); border-radius:6px; height:14px; overflow:hidden;">
|
||||
<div id="translationProgressBar" style="background:#1565c0; height:100%; width:0%; transition:width .3s;"></div>
|
||||
</div>
|
||||
<p class="text-muted" id="translationProgressText" style="margin:6px 0 0;"></p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-primary" id="translationRunBtn">Übersetzung starten</button>
|
||||
<button class="btn btn-danger" id="translationCancelBtn" style="display:none;">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organizations Section -->
|
||||
@@ -209,17 +235,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editCreditsPeriod">Abrechnungszeitraum</label>
|
||||
<select id="editCreditsPeriod" onchange="onEditPeriodChange()">
|
||||
<select id="editCreditsPeriod">
|
||||
<option value="monthly">Monatlich (füllt sich zum Monatswechsel neu)</option>
|
||||
<option value="total">Gesamtlaufzeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex; align-items:flex-end;">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
|
||||
<input type="checkbox" id="editCreditsRollover">
|
||||
<span>Übertrag ungenutzter Credits in den Folgemonat</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editWarnPercent">Warnschwelle (%, 0 = aus)</label>
|
||||
<input type="number" id="editWarnPercent" min="0" max="100" placeholder="80">
|
||||
@@ -474,17 +494,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newOrgLicCreditsPeriod">Abrechnungszeitraum</label>
|
||||
<select id="newOrgLicCreditsPeriod" onchange="onNewOrgLicPeriodChange()">
|
||||
<select id="newOrgLicCreditsPeriod">
|
||||
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
|
||||
<option value="total">Gesamtlaufzeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||
<input type="checkbox" id="newOrgLicRollover">
|
||||
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newOrgLicWarnPercent">Warnschwelle (%, 0 = aus)</label>
|
||||
<input type="number" id="newOrgLicWarnPercent" value="80" min="0" max="100">
|
||||
@@ -569,17 +583,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newLicCreditsPeriod">Abrechnungszeitraum</label>
|
||||
<select id="newLicCreditsPeriod" onchange="onNewLicPeriodChange()">
|
||||
<select id="newLicCreditsPeriod">
|
||||
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
|
||||
<option value="total">Gesamtlaufzeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||
<input type="checkbox" id="newLicRollover">
|
||||
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newLicWarnPercent">Warnschwelle (%, 0 = aus)</label>
|
||||
<input type="number" id="newLicWarnPercent" value="80" min="0" max="100">
|
||||
@@ -961,7 +969,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725x"></script>
|
||||
<script src="/static/js/app.js?v=20260725y"></script>
|
||||
<script src="/static/js/sources.js?v=20260725h"></script>
|
||||
<script src="/static/js/aufgaben.js?v=20260725d"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260725c"></script>
|
||||
|
||||
@@ -130,9 +130,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupNavTabs();
|
||||
setupOrgDetailTabs();
|
||||
setupForms();
|
||||
setupTranslation();
|
||||
loadDashboard();
|
||||
loadTariffInfo();
|
||||
loadDashboardTokenStats();
|
||||
loadTranslationStatus();
|
||||
loadOrgs();
|
||||
});
|
||||
|
||||
@@ -152,6 +154,7 @@ function setupNavTabs() {
|
||||
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById(`sec-${section}`).classList.add("active");
|
||||
|
||||
if (section === "dashboard") loadTranslationStatus();
|
||||
if (section === "audit" && typeof loadAudit === "function") loadAudit();
|
||||
});
|
||||
});
|
||||
@@ -680,10 +683,7 @@ function setupForms() {
|
||||
license_type: licType,
|
||||
max_users: parseInt(document.getElementById("newOrgLicMaxUsers").value) || 5,
|
||||
unlimited_budget: !!document.getElementById("newOrgLicUnlimited").checked,
|
||||
// Periode und Uebertrag IMMER senden, sonst laesst sich der
|
||||
// Uebertrag ueber das Formular nie abschalten
|
||||
credits_period: document.getElementById("newOrgLicCreditsPeriod").value,
|
||||
credits_rollover: !!document.getElementById("newOrgLicRollover").checked,
|
||||
};
|
||||
if (licType !== "permanent") {
|
||||
lic.duration_days = parseInt(document.getElementById("newOrgLicDuration").value) || undefined;
|
||||
@@ -775,10 +775,7 @@ function setupForms() {
|
||||
}
|
||||
const unlimitedEl = document.getElementById('newLicUnlimited');
|
||||
body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked);
|
||||
// Periode und Uebertrag IMMER senden (nicht nur bei gesetztem Wert),
|
||||
// sonst laesst sich der Uebertrag nie abschalten
|
||||
body.credits_period = document.getElementById('newLicCreditsPeriod').value;
|
||||
body.credits_rollover = !!document.getElementById('newLicRollover').checked;
|
||||
if (!body.unlimited_budget) {
|
||||
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
@@ -915,6 +912,151 @@ function formatDate(iso) {
|
||||
}
|
||||
|
||||
|
||||
// ===== Artikel-Übersetzung =====
|
||||
let translationPollTimer = null;
|
||||
|
||||
function setupTranslation() {
|
||||
const runBtn = document.getElementById("translationRunBtn");
|
||||
const cancelBtn = document.getElementById("translationCancelBtn");
|
||||
if (runBtn) runBtn.addEventListener("click", startTranslation);
|
||||
if (cancelBtn) cancelBtn.addEventListener("click", cancelTranslation);
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
seconds = Math.max(0, Math.round(seconds || 0));
|
||||
if (seconds < 60) return seconds + " Sek.";
|
||||
const min = Math.round(seconds / 60);
|
||||
if (min < 60) return min + " Min.";
|
||||
const h = Math.floor(min / 60), m = min % 60;
|
||||
return h + " Std. " + (m ? m + " Min." : "").trim();
|
||||
}
|
||||
|
||||
function renderTranslation(st) {
|
||||
const info = document.getElementById("translationInfo");
|
||||
const wrap = document.getElementById("translationProgressWrap");
|
||||
const bar = document.getElementById("translationProgressBar");
|
||||
const ptext = document.getElementById("translationProgressText");
|
||||
const runBtn = document.getElementById("translationRunBtn");
|
||||
const cancelBtn = document.getElementById("translationCancelBtn");
|
||||
if (!info || !runBtn) return;
|
||||
|
||||
if (st.running) {
|
||||
runBtn.style.display = "none";
|
||||
cancelBtn.style.display = "";
|
||||
wrap.style.display = "";
|
||||
const pct = st.total > 0 ? Math.round((st.done / st.total) * 100) : 0;
|
||||
bar.style.width = pct + "%";
|
||||
ptext.textContent = `${st.done} / ${st.total} verarbeitet, ${st.translated} übersetzt (${pct}%)`;
|
||||
info.textContent = "Übersetzung läuft…";
|
||||
return;
|
||||
}
|
||||
|
||||
runBtn.style.display = "";
|
||||
cancelBtn.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
|
||||
let resultLine = "";
|
||||
if (st.finished_at && (st.total > 0 || st.error)) {
|
||||
if (st.error) {
|
||||
resultLine = `Letzter Lauf mit Fehler beendet: ${st.error}. `;
|
||||
} else if (st.cancelled) {
|
||||
resultLine = `Letzter Lauf abgebrochen, ${st.translated} von ${st.total} Artikeln übersetzt. `;
|
||||
} else {
|
||||
resultLine = `Letzter Lauf abgeschlossen, ${st.translated} Artikel übersetzt. `;
|
||||
}
|
||||
}
|
||||
|
||||
if (st.pending > 0) {
|
||||
const est = st.estimate || {};
|
||||
info.textContent = resultLine +
|
||||
`${st.pending} Artikel ohne deutsche Übersetzung. ` +
|
||||
`Geschätzt: ${formatDuration(est.seconds)}, ca. $${est.cost_usd}.`;
|
||||
runBtn.disabled = false;
|
||||
} else {
|
||||
info.textContent = resultLine + "Alle Artikel sind übersetzt.";
|
||||
runBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranslationStatus() {
|
||||
try {
|
||||
const st = await API.get("/api/translation/status");
|
||||
renderTranslation(st);
|
||||
if (st.running && !translationPollTimer) {
|
||||
translationPollTimer = setInterval(pollTranslation, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
const info = document.getElementById("translationInfo");
|
||||
if (info) info.textContent = "Status nicht abrufbar: " + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTranslation() {
|
||||
try {
|
||||
const st = await API.get("/api/translation/status");
|
||||
renderTranslation(st);
|
||||
if (!st.running) {
|
||||
clearInterval(translationPollTimer);
|
||||
translationPollTimer = null;
|
||||
if (st.error) {
|
||||
showToast("Übersetzung mit Fehler beendet", "error");
|
||||
} else if (st.cancelled) {
|
||||
showToast(`Übersetzung abgebrochen, ${st.translated} übersetzt`, "info");
|
||||
} else {
|
||||
showToast(`Übersetzung fertig: ${st.translated} Artikel`, "success");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Translation-Poll fehlgeschlagen:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTranslation() {
|
||||
let st;
|
||||
try {
|
||||
st = await API.get("/api/translation/status");
|
||||
} catch (e) {
|
||||
showToast(e.message || "Status nicht abrufbar", "error");
|
||||
return;
|
||||
}
|
||||
if (st.running) { showToast("Es läuft bereits eine Übersetzung", "info"); return; }
|
||||
if (!st.pending) { showToast("Es gibt nichts zu übersetzen", "info"); return; }
|
||||
|
||||
const est = st.estimate || {};
|
||||
const ok = await showConfirm(
|
||||
"Übersetzung starten",
|
||||
`${st.pending} Artikel werden ins Deutsche übersetzt. ` +
|
||||
`Geschätzte Dauer: ${formatDuration(est.seconds)}, geschätzte Kosten: ca. $${est.cost_usd}. ` +
|
||||
`Der Lauf kann jederzeit abgebrochen werden.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await API.post("/api/translation/run", {});
|
||||
if (res && res.status === "started") {
|
||||
showToast(`Übersetzung gestartet (${res.pending} Artikel)`, "success");
|
||||
await loadTranslationStatus();
|
||||
if (!translationPollTimer) {
|
||||
translationPollTimer = setInterval(pollTranslation, 3000);
|
||||
}
|
||||
} else {
|
||||
showToast("Es gibt nichts zu übersetzen", "info");
|
||||
loadTranslationStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || "Start fehlgeschlagen", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTranslation() {
|
||||
try {
|
||||
await API.post("/api/translation/cancel", {});
|
||||
showToast("Übersetzung wird abgebrochen…", "info");
|
||||
} catch (e) {
|
||||
showToast(e.message || "Abbruch fehlgeschlagen", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Token-Nutzung =====
|
||||
async function loadOrgTokenUsage(orgId) {
|
||||
try {
|
||||
@@ -933,9 +1075,6 @@ async function loadOrgTokenUsage(orgId) {
|
||||
el('tokenCreditsRemaining', '∞ Unbegrenzt');
|
||||
} else {
|
||||
var remainingTxt = budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-';
|
||||
if (budget.credits_carried > 0 && budget.credits_remaining != null) {
|
||||
remainingTxt += ' (inkl. ' + Math.round(budget.credits_carried).toLocaleString('de-DE') + ' Übertrag)';
|
||||
}
|
||||
el('tokenCreditsRemaining', remainingTxt);
|
||||
}
|
||||
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
|
||||
@@ -1004,9 +1143,6 @@ function fillBudgetForm(budget) {
|
||||
el('editWarnPercent', budget.budget_warning_percent);
|
||||
const periodSel = document.getElementById('editCreditsPeriod');
|
||||
if (periodSel) periodSel.value = budget.credits_period || 'monthly';
|
||||
const rolloverCb = document.getElementById('editCreditsRollover');
|
||||
if (rolloverCb) rolloverCb.checked = !!budget.credits_rollover;
|
||||
onEditPeriodChange();
|
||||
const cb = document.getElementById('editUnlimitedBudget');
|
||||
if (cb) {
|
||||
cb.checked = !!budget.unlimited_budget;
|
||||
@@ -1019,46 +1155,26 @@ function fillBudgetForm(budget) {
|
||||
function onUnlimitedToggle() {
|
||||
const cb = document.getElementById('editUnlimitedBudget');
|
||||
const isUnlimited = cb && cb.checked;
|
||||
['editCreditsTotal','editCreditsPeriod','editCreditsRollover','editWarnPercent'].forEach(function(id) {
|
||||
['editCreditsTotal','editCreditsPeriod','editWarnPercent'].forEach(function(id) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
e.disabled = isUnlimited;
|
||||
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||
}
|
||||
});
|
||||
if (!isUnlimited) onEditPeriodChange();
|
||||
}
|
||||
|
||||
// Bei Periode "Gesamtlaufzeit" ist der Monats-Uebertrag bedeutungslos
|
||||
function onEditPeriodChange() {
|
||||
const sel = document.getElementById('editCreditsPeriod');
|
||||
const cb = document.getElementById('editCreditsRollover');
|
||||
if (!sel || !cb) return;
|
||||
const unlimitedCb = document.getElementById('editUnlimitedBudget');
|
||||
cb.disabled = sel.value === 'total' || !!(unlimitedCb && unlimitedCb.checked);
|
||||
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
|
||||
}
|
||||
|
||||
// Unlimited-Toggle im Lizenz-Modal
|
||||
function onNewLicUnlimitedToggle() {
|
||||
const cb = document.getElementById('newLicUnlimited');
|
||||
const isUnlimited = cb && cb.checked;
|
||||
['newLicCreditsTotal','newLicCreditsPeriod','newLicRollover','newLicWarnPercent'].forEach(function(id) {
|
||||
['newLicCreditsTotal','newLicCreditsPeriod','newLicWarnPercent'].forEach(function(id) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
e.disabled = isUnlimited;
|
||||
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||
}
|
||||
});
|
||||
if (!isUnlimited) onNewLicPeriodChange();
|
||||
}
|
||||
|
||||
function onNewLicPeriodChange() {
|
||||
const sel = document.getElementById('newLicCreditsPeriod');
|
||||
const cb = document.getElementById('newLicRollover');
|
||||
if (!sel || !cb) return;
|
||||
cb.disabled = sel.value === 'total';
|
||||
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
|
||||
}
|
||||
|
||||
// === Lizenz-Abschnitt im Org-Modal ===
|
||||
@@ -1080,22 +1196,13 @@ function onNewOrgLicTypeChange() {
|
||||
function onNewOrgLicUnlimitedToggle() {
|
||||
const cb = document.getElementById('newOrgLicUnlimited');
|
||||
const isUnlimited = cb && cb.checked;
|
||||
['newOrgLicCreditsTotal','newOrgLicCreditsPeriod','newOrgLicRollover','newOrgLicWarnPercent'].forEach(function(id) {
|
||||
['newOrgLicCreditsTotal','newOrgLicCreditsPeriod','newOrgLicWarnPercent'].forEach(function(id) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
e.disabled = isUnlimited;
|
||||
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||
}
|
||||
});
|
||||
if (!isUnlimited) onNewOrgLicPeriodChange();
|
||||
}
|
||||
|
||||
function onNewOrgLicPeriodChange() {
|
||||
const sel = document.getElementById('newOrgLicCreditsPeriod');
|
||||
const cb = document.getElementById('newOrgLicRollover');
|
||||
if (!sel || !cb) return;
|
||||
cb.disabled = sel.value === 'total';
|
||||
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
|
||||
}
|
||||
|
||||
async function loadDashboardTokenStats() {
|
||||
@@ -1172,12 +1279,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
if (!isUnlimited) {
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
// Periode und Uebertrag IMMER senden (nicht ueber das if-value-Muster),
|
||||
// sonst laesst sich der Uebertrag nie abschalten und die Periode nie
|
||||
// zurueckstellen. Laufzeitfelder (credits_carried, credits_period_start,
|
||||
// budget_warning_sent) sendet das Portal grundsaetzlich NICHT.
|
||||
// Periode IMMER senden (nicht ueber das if-value-Muster), sonst
|
||||
// laesst sie sich nie zurueckstellen. Laufzeitfelder
|
||||
// (credits_period_start, budget_warning_sent) sendet das Portal
|
||||
// grundsaetzlich NICHT.
|
||||
body.credits_period = document.getElementById('editCreditsPeriod').value;
|
||||
body.credits_rollover = !!document.getElementById('editCreditsRollover').checked;
|
||||
var warnPercent = document.getElementById('editWarnPercent');
|
||||
if (warnPercent && warnPercent.value) body.budget_warning_percent = parseInt(warnPercent.value);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ def test_license_create_type_pattern():
|
||||
|
||||
|
||||
def test_license_params_credits_defaults():
|
||||
"""Monatskontingent OHNE Übertrag ist die Voreinstellung."""
|
||||
"""Monatskontingent ist die Voreinstellung, einen Übertrag gibt es nicht mehr."""
|
||||
p = LicenseParams(license_type="trial")
|
||||
assert p.credits_period == "monthly"
|
||||
assert p.credits_rollover is False
|
||||
assert not hasattr(p, "credits_rollover")
|
||||
assert p.budget_warning_percent == 80
|
||||
|
||||
|
||||
@@ -71,10 +71,10 @@ def test_license_params_period_pattern():
|
||||
def test_license_create_inherits_credits_fields():
|
||||
lic = LicenseCreate(
|
||||
organization_id=1, license_type="annual",
|
||||
credits_total=10000, credits_period="monthly", credits_rollover=False,
|
||||
credits_total=10000, credits_period="monthly",
|
||||
)
|
||||
assert lic.credits_total == 10000
|
||||
assert lic.credits_rollover is False
|
||||
assert lic.credits_period == "monthly"
|
||||
|
||||
|
||||
def test_org_create_without_license_still_valid():
|
||||
@@ -102,7 +102,7 @@ def test_license_response_without_new_columns():
|
||||
notes=None, created_at="2026-07-25",
|
||||
)
|
||||
assert r.credits_period == "monthly"
|
||||
assert r.credits_carried == 0
|
||||
assert not hasattr(r, "credits_carried")
|
||||
|
||||
|
||||
def test_user_create_role_pattern():
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren