Files
certain/backend/app/routers/dashboard.py
T
GuidoandClaude Opus 4.8 2dd8f9cd35 fix: systemd ohne ProtectHome (venv unter /root sichtbar) + Phase-3-Backend
Deployment-Fixes (Dienst startete nicht -> 203/EXEC "uvicorn not found"):
- Unit ohne ProtectHome/ProtectSystem: Repo/venv dürfen unter /root oder /home
  liegen (ProtectHome=true blendete diese Pfade für den Dienst aus).
- install.sh: Python-3.11+-Prüfung mit klarer Meldung; prüft nach der Installation,
  dass uvicorn wirklich in der venv liegt.
- EZB-Kursabruf läuft beim Start jetzt im Hintergrund (blockiert den Start nie).

Phase-3-Backend (Dashboard):
- Modell + Migration 0004: dashboard_layouts (JSON-Layout je Nutzer).
- routers/dashboard: Layout speichern/laden/zurücksetzen + Kartendaten
  (Budget-Donut, Budget-Fortschritt, Einnahmen/Ausgaben, Kontostände,
  Vermögensverlauf, letzte Buchungen).
- Frontend-Infrastruktur: Chart-Komponenten (Donut/Line), palette, API-Methoden,
  react-grid-layout. (Dashboard-Seite folgt im nächsten Commit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 19:31:55 +02:00

274 lines
10 KiB
Python

"""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()
)
@router.get("/budget-donut")
def budget_donut(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)
segments = []
total = 0
for g in groups:
val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color})
total += val
mode = "planned"
if total == 0:
actual = budget.actual_expense_by_category(db, user, start, end, base)
for g in groups:
val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color})
total += val
mode = "actual"
return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label}
@router.get("/budget-progress")
def budget_progress(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)
rows = []
for g in _expense_groups(db, user):
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
if gp > 0 or ga > 0:
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
return {"rows": rows, "currency": base, "label": label}
@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 eur_effect(t: Transaction) -> int:
if t.type == TxnType.income:
return t.amount_eur_cents
if t.type == TxnType.expense:
return -t.amount_eur_cents
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang.
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
if to_acc is None:
return 0 # Zielkonto archiviert/gelöscht: ignorieren
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
return to_eur(credited, to_acc.currency, t.date) - t.amount_eur_cents
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}