diff --git a/CLAUDE.md b/CLAUDE.md index 4df5b74..827cf69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)" diff --git a/migrations/2026-07-25_credits_period.py b/migrations/2026-07-25_credits_period.py index 13d3cdc..7d6296a 100644 --- a/migrations/2026-07-25_credits_period.py +++ b/migrations/2026-07-25_credits_period.py @@ -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", } diff --git a/src/models.py b/src/models.py index ae51f2d..abf9728 100644 --- a/src/models.py +++ b/src/models.py @@ -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 diff --git a/src/routers/licenses.py b/src/routers/licenses.py index 05398a8..77667c6 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -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 diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py index 4a19671..ea2aeeb 100644 --- a/src/routers/token_usage.py +++ b/src/routers/token_usage.py @@ -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") diff --git a/src/static/dashboard.html b/src/static/dashboard.html index c399afe..e3a0bc8 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -76,6 +76,32 @@ + + +
+
+

Artikel-Übersetzung

+
+
+

+ Die automatische Übersetzung im Monitor ist deaktiviert. Hier lassen sich + fremdsprachige Artikel ohne deutsche Fassung manuell übersetzen. +

+

Status wird geladen…

+ + + +
+ + +
+
+
@@ -209,17 +235,11 @@
-
-
- -
@@ -474,17 +494,11 @@
-
-
- -
@@ -569,17 +583,11 @@
-
-
- -
@@ -961,7 +969,7 @@
- + diff --git a/src/static/js/app.js b/src/static/js/app.js index b9ecdac..e7a2dc7 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -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); } diff --git a/tests/test_models.py b/tests/test_models.py index 05a7d47..e48411b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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():