Files
bin/finance/app/routers/gui.py

182 lines
6.9 KiB
Python

from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.recurrence import occurrences
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
from app.routers.planning import list_planned as _list_planned
from app.routers.planning import list_recurring as _list_recurring
from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import list_transactions
from app.services.balances import account_balance, total_balance
from app.version import get_version
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version()
router = APIRouter()
def gui_session(request: Request):
if not session_valid(request.cookies.get(COOKIE)):
raise _redirect()
def _redirect():
exc = HTTPException(302)
exc.headers = {"Location": "/login"}
return exc
def _upcoming_items(session: Session, today: date, days: int = 30, limit: int = 10) -> list[dict]:
"""Nächste Posten aus RecurringItems (Termine) und PlannedItems der nächsten `days` Tage."""
end = today + timedelta(days=days)
items: list[dict] = []
for r in session.execute(select(RecurringItem)).scalars():
for d in occurrences(r.rhythm, r.due_day, today, end, r.start_date, r.end_date):
items.append({"date": d, "name": r.name, "amount": Decimal(r.amount)})
for p in session.execute(
select(PlannedItem).where(PlannedItem.due >= today, PlannedItem.due <= end)
).scalars():
items.append({"date": p.due, "name": p.name, "amount": Decimal(p.amount)})
items.sort(key=lambda i: i["date"])
return items[:limit]
def _latest_projection(session: Session) -> ProjectionResult | None:
return session.execute(
select(ProjectionResult).order_by(ProjectionResult.computed_at.desc())
).scalars().first()
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a)) for a in accounts]
return templates.TemplateResponse(request, "index.html", {
"balances": balances,
"total": total_balance(session),
"upcoming": _upcoming_items(session, date.today()),
"projection": _latest_projection(session),
})
@router.get("/buchungen", dependencies=[Depends(gui_session)])
def buchungen(
request: Request,
account_id: int | None = None,
date_from: date | None = None,
date_to: date | None = None,
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
session: Session = Depends(get_session),
):
txs = list_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
category_id=category_id,
q=q,
status=status,
limit=200,
offset=0,
session=session,
)
accounts = session.execute(select(Account)).scalars().all()
return templates.TemplateResponse(request, "transactions.html", {
"transactions": txs,
"accounts": accounts,
"account_names": {a.id: a.name for a in accounts},
"categories": session.execute(select(Category)).scalars().all(),
"filters": {
"account_id": account_id,
"date_from": date_from,
"date_to": date_to,
"category_id": category_id,
"q": q or "",
"status": status,
},
})
@router.get("/import", dependencies=[Depends(gui_session)])
def import_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "import.html", {
"statements": _list_imports(session=session),
})
@router.get("/import/list", dependencies=[Depends(gui_session)])
def import_list_fragment(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "_import_list.html", {
"statements": _list_imports(session=session),
})
@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)])
def import_preview_fragment(statement_id: int, request: Request,
session: Session = Depends(get_session)):
result = _preview_import(statement_id, session=session)
return templates.TemplateResponse(request, "_preview_table.html", {
"statement": result.statement,
"transactions": result.transactions,
"balance_ok": result.balance_ok,
"duplicates": result.duplicates,
})
def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]:
"""Baut je Szenario den Kontext fürs Template: zugeordnete Kredite,
Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis."""
rows = []
for sc in scenarios:
assigned_loan_ids = {
sl.loan_id for sl in session.execute(
select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id)
).scalars()
}
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"result": session.get(ProjectionResult, sc.id),
})
return rows
@router.get("/planung", dependencies=[Depends(gui_session)])
def planung_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "planning.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"suggestions": _recurring_suggestions(session=session),
"planned": planned,
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"modifier_kinds": ["percent", "absolute", "remove"],
})