feat: Die Buchungsliste laesst sich im Server durchsuchen und filtern

Drei neue Abfrageparameter an GET /api/transactions: q sucht in Empfaenger,
Verwendungszweck und Notiz, tag_id filtert auf ein Etikett, subscription_id
auf einen Vertrag. Bisher gab es das nur im Browser - die Weboberflaeche laedt
alle Buchungen und siebt sie dort. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar"; das Limit steht nicht umsonst auf 5000.

Zwei Dinge, die die Tests festhalten: Prozent- und Unterstrich-Zeichen im
Suchtext sind Zeichen, keine Platzhalter - sonst faende die Eingabe "50%"
alles. Und ein fremder tag_id liefert nichts statt alles, weil der Filter auf
TransactionTag.owner_id anschlaegt.

Der Tag-Filter nimmt eine Unterabfrage wie der uncategorized-Zweig daneben,
keinen Join: Ein Join gaebe jede Buchung so oft zurueck, wie sie Tags traegt.
Die lokale Abfragevariable hiess q und heisst jetzt abfrage, damit der
Parameter den Namen bekommen konnte.

Bekannte Grenze: SQLite gleicht bei LIKE nur ASCII gross/klein ab, "Mueller"
mit Umlaut findet also nicht jede Schreibweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 20:10:21 +02:00
co-authored by Claude Opus 5
parent e2f4e755f1
commit 1649c84f6a
2 changed files with 166 additions and 11 deletions
+40 -11
View File
@@ -9,7 +9,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select, union
from sqlalchemy import or_, select, union
from sqlalchemy.orm import Session
from ..database import get_db
@@ -194,18 +194,24 @@ def list_transactions(
type: TxnType | None = None,
date_from: date | None = None,
date_to: date | None = None,
# Suche und die zwei Filter, die es bisher nur im Browser gab. Die
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
q: str | None = None,
tag_id: int | None = None,
subscription_id: int | None = None,
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
limit: int = Query(default=5000, le=50000),
offset: int = 0,
):
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
if account_id is not None:
q = q.filter(
abfrage = abfrage.filter(
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
)
if category_id is not None:
q = q.filter(Transaction.category_id == category_id)
abfrage = abfrage.filter(Transaction.category_id == category_id)
if uncategorized:
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
@@ -214,16 +220,39 @@ def list_transactions(
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
)
q = q.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
abfrage = abfrage.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
if type is not None:
q = q.filter(Transaction.type == type)
abfrage = abfrage.filter(Transaction.type == type)
if date_from is not None:
q = q.filter(Transaction.date >= date_from)
abfrage = abfrage.filter(Transaction.date >= date_from)
if date_to is not None:
q = q.filter(Transaction.date <= date_to)
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
abfrage = abfrage.filter(Transaction.date <= date_to)
if q:
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
muster = "%" + q.translate(
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
) + "%"
abfrage = abfrage.filter(or_(
Transaction.payee.ilike(muster, escape="\\"),
Transaction.description.ilike(muster, escape="\\"),
Transaction.notes.ilike(muster, escape="\\"),
))
if tag_id is not None:
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
# jede Buchung so oft liefern, wie sie Tags trägt.
mit_tag = select(TransactionTag.transaction_id).where(
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
)
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
if subscription_id is not None:
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
rows = abfrage.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
base = base_currency(db, user)
ccy = _currency_map(db, user)
woher = _herkunft(db, user, rows)