Files
certain/backend/app/routers/dashboard.py
T
GuidoandClaude Opus 5 63056ae76a feat: Ringdiagramm faerbt Legende und Mitte nach Budget
Eingefaerbt war bisher nur die Listen-Darstellung der Ausgaben-Anteile. In der
Kreis-Darstellung blieben die Betraege der Legende grau, und die Summe in der
Mitte sagte gar nichts ueber den Stand.

Jetzt tragen beide die Aussage: gruen im geplanten Rahmen, rot darueber. Faehrt
man ein Segment an, zeigt die Mitte dessen Betrag – dann gilt dort das Budget
dieser Kategorie, sonst das des Ganzen.

Fuer die Mitte liefert der Server jetzt auch das Geplante ueber ALLE
Ausgaben-Einheiten, nicht nur ueber die mit Ausgaben: Der Monatsplan umfasst
auch, wofuer noch nichts ausgegeben wurde.

Ohne Plan bleibt alles, wie es war. Der Donut faerbt nur, wenn ein Segment die
Angabe mitbringt – die Budget-Karte, die dieselbe Komponente benutzt, bleibt
also unberuehrt.

Der Test hat dabei gleich einen Fehler gefunden: ``_units`` ist ein Generator,
und ich lief zweimal darueber – beim zweiten Mal war er leer und das Geplante
immer 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:49 +02:00

351 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dashboard: pro Nutzer gespeichertes Karten-Layout und die Datenendpunkte je Karte."""
import json
from collections import defaultdict
from datetime import date, timedelta
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import (
Account,
Category,
CategoryGroup,
CategoryKind,
DashboardLayout,
Transaction,
TxnType,
User,
)
from ..services import balances, budget, currency
from .accounts import base_currency
from .settings import BUDGET_START_DAY_KEY, get_user_setting
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
# Sinnvolles Standard-Layout (12-Spalten-Raster). Eine Karte je Typ.
DEFAULT_LAYOUT = [
{"i": "account-balances", "type": "account-balances", "x": 0, "y": 0, "w": 4, "h": 5},
{"i": "budget-donut", "type": "budget-donut", "x": 4, "y": 0, "w": 4, "h": 5},
{"i": "income-expense", "type": "income-expense", "x": 8, "y": 0, "w": 4, "h": 2},
{"i": "budget-progress", "type": "budget-progress", "x": 8, "y": 2, "w": 4, "h": 3},
{"i": "net-worth", "type": "net-worth", "x": 0, "y": 5, "w": 8, "h": 4},
{"i": "recent-transactions", "type": "recent-transactions", "x": 8, "y": 5, "w": 4, "h": 4},
]
def _start_day(db: Session, user: User) -> int:
try:
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
except ValueError:
return 1
def _label(db: Session, user: User) -> str:
return budget.current_label(_start_day(db, user))
# --- Layout ----------------------------------------------------------------
@router.get("/layout")
def get_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
if row is None:
return {"layout": DEFAULT_LAYOUT, "default": True}
try:
layout = json.loads(row.layout)
except ValueError:
layout = DEFAULT_LAYOUT
return {"layout": layout, "default": False}
@router.put("/layout")
def save_layout(
layout: list = Body(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
if row is None:
row = DashboardLayout(owner_id=user.id, layout=json.dumps(layout))
db.add(row)
else:
row.layout = json.dumps(layout)
db.commit()
return {"layout": layout, "default": False}
@router.delete("/layout")
def reset_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
if row is not None:
db.delete(row)
db.commit()
return {"layout": DEFAULT_LAYOUT, "default": True}
# --- Kartendaten -----------------------------------------------------------
def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
return (
db.query(CategoryGroup)
.filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense)
.order_by(CategoryGroup.position, CategoryGroup.name)
.all()
)
def _units(groups: list[CategoryGroup], by_category: bool):
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
for g in groups:
if by_category:
for c in g.categories:
if not c.is_archived:
yield (c.name, c.color, [c.id])
else:
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
@router.get("/budget-donut")
def budget_donut(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
groups = _expense_groups(db, user)
by_category = group_by == "category"
def collect(values: dict[int, int]):
segs, tot = [], 0
for name, color, ids in _units(groups, by_category):
val = sum(values.get(i, 0) for i in ids)
if val > 0:
segs.append({"name": name, "value": val, "color": color})
tot += val
return segs, tot
segments, total = collect(planned)
mode = "planned"
if total == 0:
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
mode = "actual"
return {"segments": segments, "total": total, "currency": base, "mode": mode,
"label": label, "group_by": "category" if by_category else "group"}
@router.get("/expense-share")
def expense_share(
group_by: str = Query(default="category"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe.
Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig
davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die
Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu
99,9 % addieren."""
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
actual = budget.actual_expense_by_category(db, user, start, end, base)
# Das Geplante kommt mit, damit die Karte zeigen kann, ob ein Anteil noch
# im Rahmen liegt. Ohne Plan wird nichts behauptet: „ueber Budget" waere
# sonst die Aussage ueber jede Kategorie, fuer die nie eine gemacht wurde.
planned = budget.effective_planned(db, user, label)
by_category = group_by != "group"
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
einheiten = list(_units(_expense_groups(db, user), by_category))
rows = []
for name, color, ids in einheiten:
val = sum(actual.get(i, 0) for i in ids)
if val > 0:
geplant = sum(planned.get(i, 0) for i in ids)
rows.append({"name": name, "color": color, "amount": val,
"planned": geplant,
"over": geplant > 0 and val > geplant})
rows.sort(key=lambda r: r["amount"], reverse=True)
# Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben:
# Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde.
gesamt = sum(r["amount"] for r in rows)
gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids)
return {
"rows": rows,
"total": gesamt,
"total_planned": gesamt_geplant,
"total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant,
"currency": base,
"label": label,
"group_by": "category" if by_category else "group",
}
@router.get("/budget-progress")
def budget_progress(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, start, end, base)
by_category = group_by == "category"
rows = []
for name, color, ids in _units(_expense_groups(db, user), by_category):
gp = sum(planned.get(i, 0) for i in ids)
ga = sum(actual.get(i, 0) for i in ids)
if gp > 0 or ga > 0:
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
return {"rows": rows, "currency": base, "label": label,
"group_by": "category" if by_category else "group"}
@router.get("/income-expense")
def income_expense(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values())
income = sum(budget.income_by_category(db, user, start, end, base).values())
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
@router.get("/balances")
def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
base = base_currency(db, user)
today = date.today()
accounts = (
db.query(Account)
.filter(Account.owner_id == user.id)
.order_by(Account.position, Account.name)
.all()
)
rows = []
total = 0
for a in accounts:
bb = balances.account_balance_base_cents(db, a, base, today)
rows.append({
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
"balance_base_cents": bb, "archived": a.is_archived,
})
if not a.is_archived:
total += bb
return {"accounts": rows, "total": total, "currency": base}
@router.get("/net-worth")
def net_worth(
days: int = Query(default=90, ge=7, le=365),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
end = date.today()
start = end - timedelta(days=days - 1)
accounts = db.query(Account).filter(Account.owner_id == user.id, Account.is_archived == False).all() # noqa: E712
acc_by_id = {a.id: a for a in accounts}
def to_eur(cents: int, ccy: str, on: date) -> int:
v = currency.to_eur_cents(db, cents, ccy, on)
return v if v is not None else cents
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
def zaehlt(acc: Account | None, on: date) -> bool:
"""Buchungen bis zum Stichtag stecken schon im Anfangssaldo."""
return acc is not None and (acc.opening_date is None or on > acc.opening_date)
def eur_effect(t: Transaction) -> int:
# Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
# anders liegen als der des Zielkontos.
effekt = 0
if zaehlt(acc_by_id.get(t.account_id), t.date):
effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
if t.type == TxnType.transfer:
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
if zaehlt(to_acc, t.date):
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
effekt += to_eur(credited, to_acc.currency, t.date)
return effekt
txns = db.query(Transaction).filter(
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
).all() if acc_by_id else []
before = sum(eur_effect(t) for t in txns if t.date < start)
deltas: dict[date, int] = defaultdict(int)
for t in txns:
if start <= t.date <= end:
deltas[t.date] += eur_effect(t)
running = opening_eur + before
labels: list[str] = []
values_eur: list[int] = []
d = start
while d <= end:
running += deltas.get(d, 0)
labels.append(d.isoformat())
values_eur.append(running)
d += timedelta(days=1)
if base != "EUR":
rate = currency.rate_on(db, base, end)
if rate is not None:
values_eur = [int(round(v * float(rate))) for v in values_eur]
return {"labels": labels, "values": values_eur, "currency": base}
@router.get("/recent")
def recent(
limit: int = Query(default=8, le=50),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
rows = (
db.query(Transaction)
.filter(Transaction.owner_id == user.id)
.order_by(Transaction.date.desc(), Transaction.id.desc())
.limit(limit)
.all()
)
out = []
for t in rows:
acc = accs.get(t.account_id)
if t.type == TxnType.transfer:
to = accs.get(t.to_account_id)
title = f"{acc.name if acc else '?'} → {to.name if to else '?'}"
icon, color, sign = "arrow-left-right", "#6b7480", None
else:
cat = cats.get(t.category_id) if t.category_id else None
title = cat.name if cat else (t.payee or "—")
icon = cat.icon if cat else "more-horizontal"
color = cat.color if cat else "#6b7480"
sign = "pos" if t.type == TxnType.income else "neg"
out.append({
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
"title": title, "icon": icon, "color": color, "sign": sign,
"amount_cents": t.amount_cents, "currency": acc.currency if acc else "EUR",
"amount_base_cents": t.amount_eur_cents if base == "EUR"
else (currency.from_eur_cents(db, t.amount_eur_cents, base, t.date) or t.amount_eur_cents),
})
return {"transactions": out, "currency": base}