feat: Web-GUI Basis, Uebersicht, Buchungsliste
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import FastAPI, Form
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import auth
|
||||
from app.config import get_settings
|
||||
from app.routers import (accounts, categories, imports, planning, scenarios,
|
||||
transactions)
|
||||
from app.routers import (accounts, categories, gui, imports, planning,
|
||||
scenarios, transactions)
|
||||
from app.routers.gui import templates
|
||||
|
||||
app = FastAPI(title="Finanzberatungs-Tool")
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(gui.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(transactions.router)
|
||||
app.include_router(categories.router)
|
||||
@@ -18,12 +23,9 @@ app.include_router(planning.router)
|
||||
app.include_router(scenarios.router)
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
def login_form():
|
||||
return """<form method=post action=/login>
|
||||
<input name=username placeholder=Benutzer>
|
||||
<input name=password type=password placeholder=Passwort>
|
||||
<button>Anmelden</button></form>"""
|
||||
@app.get("/login")
|
||||
def login_form(request: Request):
|
||||
return templates.TemplateResponse(request, "login.html", {})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
|
||||
111
finance/app/routers/gui.py
Normal file
111
finance/app/routers/gui.py
Normal file
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
from app.routers.transactions import list_transactions
|
||||
from app.services.balances import account_balance, total_balance
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
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,
|
||||
)
|
||||
return templates.TemplateResponse(request, "transactions.html", {
|
||||
"transactions": txs,
|
||||
"accounts": session.execute(select(Account)).scalars().all(),
|
||||
"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):
|
||||
# Platzhalter, wird in Task 13 mit Upload-/Vorschau-Funktion gefüllt.
|
||||
return templates.TemplateResponse(request, "import.html", {})
|
||||
|
||||
|
||||
@router.get("/planung", dependencies=[Depends(gui_session)])
|
||||
def planung_page(request: Request):
|
||||
# Platzhalter, wird in Task 13 mit Fixposten-/Szenario-Verwaltung gefüllt.
|
||||
return templates.TemplateResponse(request, "planung.html", {})
|
||||
1
finance/app/static/htmx.min.js
vendored
Normal file
1
finance/app/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
94
finance/app/static/style.css
Normal file
94
finance/app/static/style.css
Normal file
@@ -0,0 +1,94 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: #06c;
|
||||
}
|
||||
|
||||
nav form {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.neg {
|
||||
color: #b00;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #e0c26b;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.total-balance {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
form.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
form.filter-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
iframe.grafana {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
25
finance/app/templates/base.html
Normal file
25
finance/app/templates/base.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Übersicht</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/buchungen">Buchungen</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
6
finance/app/templates/import.html
Normal file
6
finance/app/templates/import.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Import – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Import</h1>
|
||||
<p>Der Kontoauszug-Import (Upload, Vorschau, Bestätigen) folgt in einem späteren Ausbauschritt.</p>
|
||||
{% endblock %}
|
||||
54
finance/app/templates/index.html
Normal file
54
finance/app/templates/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Übersicht – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Übersicht</h1>
|
||||
|
||||
{% if projection and projection.below_threshold_date %}
|
||||
<div class="warning">
|
||||
Achtung: Prognostizierter Saldo unterschreitet den Schwellenwert am
|
||||
<strong>{{ projection.below_threshold_date.strftime('%d.%m.%Y') }}</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
|
||||
Gesamtsaldo: {{ '%.2f'|format(total) }} €
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Konto</th><th>Bank</th><th>Saldo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td>{{ account.name }}</td>
|
||||
<td>{{ account.bank }}</td>
|
||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Nächste anstehende Posten (30 Tage)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Bezeichnung</th><th>Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in upcoming %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ '%.2f'|format(item.amount) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Grafana-Dashboard</h2>
|
||||
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097"></iframe>
|
||||
{% endblock %}
|
||||
21
finance/app/templates/login.html
Normal file
21
finance/app/templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – Finanzberatung</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Finanzberatung</h1>
|
||||
<form method="post" action="/login">
|
||||
<label>Benutzer
|
||||
<input name="username" placeholder="Benutzer">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" placeholder="Passwort">
|
||||
</label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
6
finance/app/templates/planung.html
Normal file
6
finance/app/templates/planung.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Planung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Planung</h1>
|
||||
<p>Die Verwaltung von Fixposten, Einmalposten und Szenarien folgt in einem späteren Ausbauschritt.</p>
|
||||
{% endblock %}
|
||||
146
finance/app/templates/transactions.html
Normal file
146
finance/app/templates/transactions.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Buchungen – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Buchungen</h1>
|
||||
|
||||
<form class="filter-form" method="get" action="/buchungen">
|
||||
<label>Konto
|
||||
<select name="account_id">
|
||||
<option value="">alle</option>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}" {% if filters.account_id == a.id %}selected{% endif %}>{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Von
|
||||
<input type="date" name="date_from" value="{{ filters.date_from.isoformat() if filters.date_from else '' }}">
|
||||
</label>
|
||||
<label>Bis
|
||||
<input type="date" name="date_to" value="{{ filters.date_to.isoformat() if filters.date_to else '' }}">
|
||||
</label>
|
||||
<label>Kategorie
|
||||
<select name="category_id">
|
||||
<option value="">alle</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if filters.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Suche
|
||||
<input type="text" name="q" value="{{ filters.q }}" placeholder="Verwendungszweck / Empfänger">
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{% for s in ("confirmed", "draft") %}
|
||||
<option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
||||
<th>Betrag</th><th>Kategorie</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ tx.account_id }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
|
||||
<td>
|
||||
<select name="category_id" data-type="int" hx-ext="json-form"
|
||||
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Regel erzeugen</summary>
|
||||
<form hx-ext="json-form" hx-post="/api/category-rules" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.closest('details').open=false}">
|
||||
<input type="hidden" name="pattern" value="{{ tx.counterparty }}">
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int" required>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Regel speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Keine Buchungen gefunden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Buchung manuell erfassen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/transactions" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.reset(); window.location.reload()}">
|
||||
<label>Konto
|
||||
<select name="account_id" data-type="int" required>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Buchungsdatum
|
||||
<input type="date" name="booking_date" required>
|
||||
</label>
|
||||
<label>Betrag
|
||||
<input type="text" name="amount" placeholder="-12.50" required>
|
||||
</label>
|
||||
<label>Verwendungszweck
|
||||
<input type="text" name="purpose">
|
||||
</label>
|
||||
<label>Empfänger/Zahler
|
||||
<input type="text" name="counterparty">
|
||||
</label>
|
||||
<button type="submit">Buchung speichern</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<script>
|
||||
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
|
||||
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
|
||||
// kodiert, leere Werte als null, alles andere bleibt String.
|
||||
htmx.defineExtension('json-form', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
},
|
||||
encodeParameters: function (xhr, parameters, elt) {
|
||||
var out = {};
|
||||
for (var k in parameters) {
|
||||
var v = parameters[k];
|
||||
var field = elt.querySelector('[name="' + k + '"]');
|
||||
var kind = field && field.dataset ? field.dataset.type : null;
|
||||
if (v === '') {
|
||||
out[k] = null;
|
||||
} else if (kind === 'int') {
|
||||
out[k] = parseInt(v, 10);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
xhr.overrideMimeType('text/json');
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
13
finance/tests/test_gui.py
Normal file
13
finance/tests/test_gui.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def test_pages_require_login(client):
|
||||
for path in ("/", "/buchungen", "/import", "/planung"):
|
||||
r = client.get(path, follow_redirects=False)
|
||||
assert r.status_code in (302, 303), path
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_pages_render_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/buchungen"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "Finanzberatung" in r.text
|
||||
Reference in New Issue
Block a user