fix: tolerante GUI-Filter, sichtbare Bedienelemente, fixierte Navigation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,28 @@ def _latest_projection(session: Session) -> ProjectionResult | None:
|
|||||||
).scalars().first()
|
).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_int(value: str | None) -> int | None:
|
||||||
|
"""Wandelt einen GUI-Query-Parameter tolerant in int|None um. HTML-Formulare
|
||||||
|
senden bei nicht ausgefüllten Feldern leere Strings statt gar keinen
|
||||||
|
Parameter - das darf nie zu einem 422 fuehren, sondern degradiert zu None."""
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_date(value: str | None) -> date | None:
|
||||||
|
"""Wie _opt_int, nur fuer date-Felder (ISO-Format aus <input type=date>)."""
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", dependencies=[Depends(gui_session)])
|
@router.get("/", dependencies=[Depends(gui_session)])
|
||||||
def index(request: Request, session: Session = Depends(get_session)):
|
def index(request: Request, session: Session = Depends(get_session)):
|
||||||
accounts = session.execute(select(Account)).scalars().all()
|
accounts = session.execute(select(Account)).scalars().all()
|
||||||
@@ -77,15 +99,23 @@ def index(request: Request, session: Session = Depends(get_session)):
|
|||||||
@router.get("/buchungen", dependencies=[Depends(gui_session)])
|
@router.get("/buchungen", dependencies=[Depends(gui_session)])
|
||||||
def buchungen(
|
def buchungen(
|
||||||
request: Request,
|
request: Request,
|
||||||
account_id: int | None = None,
|
account_id: str | None = None,
|
||||||
date_from: date | None = None,
|
date_from: str | None = None,
|
||||||
date_to: date | None = None,
|
date_to: str | None = None,
|
||||||
category_id: int | None = None,
|
category_id: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
status: str = "confirmed",
|
status: str = "confirmed",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
|
||||||
|
# daher hier bewusst str|None statt int|None/date|None und tolerante
|
||||||
|
# Konvertierung, statt FastAPI vor dem Handler mit 422 abbrechen zu lassen.
|
||||||
|
# Die API-Routen (/api/transactions) bleiben strikt typisiert.
|
||||||
|
account_id = _opt_int(account_id)
|
||||||
|
date_from = _opt_date(date_from)
|
||||||
|
date_to = _opt_date(date_to)
|
||||||
|
category_id = _opt_int(category_id)
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
total = count_transactions(
|
total = count_transactions(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ nav {
|
|||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
nav a {
|
||||||
@@ -102,6 +106,11 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.15rem 0.6rem;
|
padding: 0.15rem 0.6rem;
|
||||||
|
|||||||
@@ -39,19 +39,24 @@
|
|||||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||||
<button type="submit">Verwerfen</button>
|
<button type="submit">Verwerfen</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
|
||||||
{% elif s.status == "confirmed" %}
|
{% elif s.status == "confirmed" %}
|
||||||
<span class="muted">Bestätigt — Buchungen übernommen</span>
|
<span class="muted">Bestätigt — Buchungen übernommen</span>
|
||||||
|
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
|
||||||
|
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Verwerfen</button>
|
||||||
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/rollback" hx-swap="none"
|
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/rollback" hx-swap="none"
|
||||||
hx-confirm="Diesen Import und ALLE zugehörigen Buchungen unwiderruflich löschen? Salden und Projektionen ändern sich."
|
hx-confirm="Diesen Import und ALLE zugehörigen Buchungen unwiderruflich löschen? Salden und Projektionen ändern sich."
|
||||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||||
<button type="submit">Import zurückrollen</button>
|
<button type="submit">Import zurückrollen</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
|
||||||
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
||||||
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
||||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||||
<button type="submit">Verwerfen</button>
|
<button type="submit">Verwerfen</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -53,9 +53,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{% if suggestions %}
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Vorschläge aus Buchungen</legend>
|
<legend>Vorschläge aus Buchungen</legend>
|
||||||
|
{% if suggestions %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
|
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
|
||||||
@@ -82,8 +82,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</fieldset>
|
{% else %}
|
||||||
|
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="planning-section">
|
<section class="planning-section">
|
||||||
|
|||||||
@@ -78,6 +78,46 @@ def test_buchungen_pagination(client, db):
|
|||||||
assert "MARKER51" in r2.text
|
assert "MARKER51" in r2.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchungen_empty_filter_fields_no_422(client):
|
||||||
|
# HTML-Formulare senden bei nicht ausgefüllten Feldern leere Strings, nicht
|
||||||
|
# "kein Parameter" — die GUI-Route muss das tolerant behandeln statt mit
|
||||||
|
# einem rohen 422 zu scheitern.
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
r = client.get(
|
||||||
|
"/buchungen"
|
||||||
|
"?account_id=2&date_from=&date_to=&category_id=&q=&status=confirmed"
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchungen_filled_filters_scope_to_account(client, db):
|
||||||
|
from app.models.tables import Account, Transaction
|
||||||
|
|
||||||
|
acc_a = Account(bank="dkb", iban="DE01", name="Konto A", type="giro")
|
||||||
|
acc_b = Account(bank="dkb", iban="DE02", name="Konto B", type="giro")
|
||||||
|
db.add_all([acc_a, acc_b])
|
||||||
|
db.flush()
|
||||||
|
db.add(Transaction(
|
||||||
|
account_id=acc_a.id, booking_date=date(2026, 6, 15),
|
||||||
|
amount=Decimal("10.00"), purpose="MARKER-A", counterparty="X",
|
||||||
|
status="confirmed", dedup_hash="hash-a",
|
||||||
|
))
|
||||||
|
db.add(Transaction(
|
||||||
|
account_id=acc_b.id, booking_date=date(2026, 6, 15),
|
||||||
|
amount=Decimal("20.00"), purpose="MARKER-B", counterparty="Y",
|
||||||
|
status="confirmed", dedup_hash="hash-b",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
r = client.get(
|
||||||
|
f"/buchungen?account_id={acc_a.id}&date_from=2026-06-01&date_to=2026-06-30"
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "MARKER-A" in r.text
|
||||||
|
assert "MARKER-B" not in r.text
|
||||||
|
|
||||||
|
|
||||||
def test_planning_page_has_all_sections(client):
|
def test_planning_page_has_all_sections(client):
|
||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
r = client.get("/planung")
|
r = client.get("/planung")
|
||||||
|
|||||||
Reference in New Issue
Block a user