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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user