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
|
import hmac
|
||||||
|
|
||||||
from fastapi import FastAPI, Form
|
from fastapi import FastAPI, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.routers import (accounts, categories, imports, planning, scenarios,
|
from app.routers import (accounts, categories, gui, imports, planning,
|
||||||
transactions)
|
scenarios, transactions)
|
||||||
|
from app.routers.gui import templates
|
||||||
|
|
||||||
app = FastAPI(title="Finanzberatungs-Tool")
|
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(accounts.router)
|
||||||
app.include_router(transactions.router)
|
app.include_router(transactions.router)
|
||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
@@ -18,12 +23,9 @@ app.include_router(planning.router)
|
|||||||
app.include_router(scenarios.router)
|
app.include_router(scenarios.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@app.get("/login")
|
||||||
def login_form():
|
def login_form(request: Request):
|
||||||
return """<form method=post action=/login>
|
return templates.TemplateResponse(request, "login.html", {})
|
||||||
<input name=username placeholder=Benutzer>
|
|
||||||
<input name=password type=password placeholder=Passwort>
|
|
||||||
<button>Anmelden</button></form>"""
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/login")
|
@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