182 lines
6.9 KiB
Python
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"],
|
|
})
|