Compare commits
3
Commits
e2f4e755f1
...
3dff435156
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dff435156 | ||
|
|
82edc29dfe | ||
|
|
1649c84f6a |
@@ -46,7 +46,19 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines],
|
||||
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
|
||||
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
|
||||
# berechnete Zeile als feste an und schreibt sie beim nächsten
|
||||
# Speichern auch als feste zurück.
|
||||
"lines": [
|
||||
{
|
||||
"category_id": ln.category_id,
|
||||
"planned_cents": ln.planned_cents,
|
||||
"modus": ln.modus,
|
||||
"zuschlag_cents": ln.zuschlag_cents,
|
||||
}
|
||||
for ln in lines
|
||||
],
|
||||
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Suche und Filter auf der Buchungsliste: ``q``, ``tag_id``, ``subscription_id``.
|
||||
|
||||
Bisher siebte die Weboberflaeche im Browser und lud dafuer alles, was es gab -
|
||||
das Limit steht nicht umsonst auf 5000. Am Telefon ist das der Unterschied
|
||||
zwischen "laedt" und "benutzbar", deshalb sitzen die drei Filter jetzt im
|
||||
Server.
|
||||
|
||||
Die Datenbank lebt ueber den ganzen Testlauf. Texte deshalb eindeutig
|
||||
wuerfeln, sonst findet die Suche die Daten anderer Tests mit.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def _konto(admin):
|
||||
return admin.post("/api/accounts", json={
|
||||
"name": f"Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "EUR", "opening_balance_cents": 0}).json()
|
||||
|
||||
|
||||
def _buchung(admin, konto, **felder):
|
||||
r = admin.post("/api/transactions", json={
|
||||
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
|
||||
"amount_cents": 100 + secrets.randbelow(9000), **felder})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _suche(admin, **filter_):
|
||||
r = admin.get("/api/transactions", params=filter_)
|
||||
assert r.status_code == 200, r.text
|
||||
return [t["id"] for t in r.json()]
|
||||
|
||||
|
||||
def test_suche_findet_empfaenger_verwendungszweck_und_notiz(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Zebra{secrets.token_hex(4)}"
|
||||
ueber_empfaenger = _buchung(admin, konto, payee=f"Laden {wort}")
|
||||
ueber_zweck = _buchung(admin, konto, payee="Egal", description=f"Rechnung {wort}")
|
||||
ueber_notiz = _buchung(admin, konto, payee="Egal", notes=f"wegen {wort}")
|
||||
daneben = _buchung(admin, konto, payee="Etwas voellig anderes")
|
||||
|
||||
gefunden = _suche(admin, q=wort)
|
||||
assert set(gefunden) == {ueber_empfaenger["id"], ueber_zweck["id"], ueber_notiz["id"]}
|
||||
assert daneben["id"] not in gefunden
|
||||
|
||||
|
||||
def test_suche_achtet_nicht_auf_gross_und_kleinschreibung(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Kiosk{secrets.token_hex(4)}"
|
||||
txn = _buchung(admin, konto, payee=wort.upper())
|
||||
|
||||
assert txn["id"] in _suche(admin, q=wort.lower())
|
||||
|
||||
|
||||
def test_prozentzeichen_im_suchtext_bleibt_ein_zeichen(admin):
|
||||
"""Sonst waere jede Eingabe mit % ein Platzhalter und faende alles."""
|
||||
konto = _konto(admin)
|
||||
marke = secrets.token_hex(4)
|
||||
mit_prozent = _buchung(admin, konto, payee=f"Rabatt%{marke}")
|
||||
ohne = _buchung(admin, konto, payee=f"Rabatt {marke}")
|
||||
|
||||
gefunden = _suche(admin, q=f"Rabatt%{marke}")
|
||||
assert gefunden == [mit_prozent["id"]]
|
||||
assert ohne["id"] not in gefunden
|
||||
|
||||
|
||||
def test_tag_filter_zeigt_nur_buchungen_mit_diesem_tag(admin):
|
||||
konto = _konto(admin)
|
||||
tag = admin.post("/api/tags", json={
|
||||
"name": f"Tag {secrets.token_hex(4)}", "color": "#3f51b5"}).json()
|
||||
mit = _buchung(admin, konto, payee="Mit Etikett")
|
||||
ohne = _buchung(admin, konto, payee="Ohne Etikett")
|
||||
admin.patch(f"/api/transactions/{mit['id']}", json={"tag_ids": [tag["id"]]})
|
||||
|
||||
gefunden = _suche(admin, tag_id=tag["id"])
|
||||
assert gefunden == [mit["id"]]
|
||||
assert ohne["id"] not in gefunden
|
||||
|
||||
|
||||
def test_tag_eines_fremden_nutzers_zeigt_keine_buchungen(admin):
|
||||
"""Mandanten-Isolation: Eine geratene fremde ID darf nichts aufschliessen."""
|
||||
konto = _konto(admin)
|
||||
meine = _buchung(admin, konto, payee="Meine Buchung")
|
||||
|
||||
name, passwort = new_user(admin)
|
||||
fremd, _ = login_ready(name, passwort)
|
||||
fremder_tag = fremd.post("/api/tags", json={
|
||||
"name": f"Fremd {secrets.token_hex(4)}", "color": "#eb6834"}).json()
|
||||
|
||||
gefunden = _suche(admin, tag_id=fremder_tag["id"])
|
||||
assert gefunden == []
|
||||
assert meine["id"] not in gefunden
|
||||
|
||||
|
||||
def test_abo_filter_zeigt_nur_buchungen_dieses_vertrags(admin):
|
||||
konto = _konto(admin)
|
||||
abo = admin.post("/api/subscriptions", json={
|
||||
"name": f"Dienst {secrets.token_hex(4)}", "amount_cents": 999,
|
||||
"currency": "EUR", "interval_months": 1}).json()
|
||||
zugeordnet = _buchung(admin, konto, payee="Monatsbeitrag")
|
||||
frei = _buchung(admin, konto, payee="Einmalkauf")
|
||||
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{zugeordnet['id']}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
gefunden = _suche(admin, subscription_id=abo["id"])
|
||||
assert gefunden == [zugeordnet["id"]]
|
||||
assert frei["id"] not in gefunden
|
||||
|
||||
|
||||
def test_suche_und_tag_filter_greifen_zusammen(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Baumarkt{secrets.token_hex(4)}"
|
||||
tag = admin.post("/api/tags", json={
|
||||
"name": f"Tag {secrets.token_hex(4)}", "color": "#2a78d6"}).json()
|
||||
beides = _buchung(admin, konto, payee=f"{wort} Farbe")
|
||||
nur_text = _buchung(admin, konto, payee=f"{wort} Schrauben")
|
||||
nur_tag = _buchung(admin, konto, payee="Etwas anderes")
|
||||
for txn in (beides, nur_tag):
|
||||
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
|
||||
|
||||
gefunden = _suche(admin, q=wort, tag_id=tag["id"])
|
||||
assert gefunden == [beides["id"]]
|
||||
assert nur_text["id"] not in gefunden and nur_tag["id"] not in gefunden
|
||||
@@ -369,3 +369,33 @@ def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin):
|
||||
assert not any(r["id"] == tx["id"] for r in auf["rows"])
|
||||
karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json()
|
||||
assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"]
|
||||
|
||||
|
||||
def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
||||
"""Die Vorlage gab bisher nur planned_cents heraus.
|
||||
|
||||
Wer sie so liest, haelt eine berechnete Zeile fuer eine feste - und
|
||||
schreibt sie beim naechsten Speichern auch als feste zurueck. PUT und die
|
||||
Monatsansicht nennen beide Felder laengst; hier fehlten sie schlicht.
|
||||
|
||||
Eine eigene Kategorie statt Lebensmittel: Die Testdatenbank lebt ueber den
|
||||
ganzen Lauf, und eine Vorlagenzeile auf "berechnet" umzustellen wuerde die
|
||||
Nachbartests treffen.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
gruppe = next(g for g in admin.get("/api/categories").json()
|
||||
if g["kind"] == "expense")
|
||||
kat = admin.post("/api/categories", json={
|
||||
"group_id": gruppe["id"], "name": f"Vorlagenart {secrets.token_hex(3)}"}).json()
|
||||
|
||||
r = admin.put(f"/api/budget/template/category/{kat['id']}",
|
||||
params={"planned_cents": 12000, "modus": "berechnet",
|
||||
"zuschlag_cents": 2500})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(l for l in admin.get("/api/budget/template").json()["lines"]
|
||||
if l["category_id"] == kat["id"])
|
||||
assert zeile["planned_cents"] == 12000
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 2500
|
||||
|
||||
+45
-26
@@ -146,13 +146,15 @@ Die App muss sie nachbauen, das Backend hilft nicht:
|
||||
nur die Summe; welche Abos dahinterstehen, ermittelt das Frontend selbst aus
|
||||
`/subscriptions` nach `category_id`.
|
||||
|
||||
### Buchungsliste ohne Suche
|
||||
### Buchungsliste
|
||||
|
||||
`GET /api/transactions` kennt nur `account_id`, `category_id`, `uncategorized`,
|
||||
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset`. **Keine**
|
||||
Volltextsuche, kein Tag-Filter, keine wählbare Sortierung. Die Weboberfläche
|
||||
lädt alles und filtert im Browser. Auf dem Telefon ist das spürbar — daher
|
||||
Backend-Ergänzung B unten.
|
||||
`GET /api/transactions` kennt `account_id`, `category_id`, `uncategorized`,
|
||||
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset` und seit
|
||||
Ergänzung B auch `q`, `tag_id`, `subscription_id`. **Keine** wählbare
|
||||
Sortierung — immer Datum absteigend, dann ID absteigend.
|
||||
|
||||
Die Weboberfläche lädt weiter alles und filtert im Browser; sie wurde nicht
|
||||
umgestellt, weil sie so funktioniert. Die App soll die Filter benutzen.
|
||||
|
||||
### Was nicht in die App kommt
|
||||
|
||||
@@ -171,7 +173,7 @@ Backend-Ergänzung B unten.
|
||||
|
||||
## 4. Backend-Ergänzungen
|
||||
|
||||
Vier geplant. **A ist fertig**, B bis D stehen aus. Jede einzeln dem Nutzer
|
||||
Vier geplant. **A, B und C sind fertig**, D steht aus. Jede einzeln dem Nutzer
|
||||
vorlegen, bevor du sie schreibst.
|
||||
|
||||
### A — App-Token ✅ (Commit `8a7545b`)
|
||||
@@ -192,21 +194,36 @@ Zwei Dinge, die die Tests festhalten (`backend/tests/test_app_marke.py`,
|
||||
`POST /api/inbox/tokens`). Sonst stellte ein Dieb sich Ersatzschlüssel aus
|
||||
und das Widerrufen liefe ins Leere.
|
||||
|
||||
### B — Suche und Tag-Filter auf `/transactions` (offen)
|
||||
### B — Suche und Tag-Filter auf `/transactions` ✅
|
||||
|
||||
Drei Query-Parameter: `q` (Volltext über `payee`, `description`, `notes`),
|
||||
`tag_id`, `subscription_id`. Muster für den Tag-Filter steht schon da: der
|
||||
`uncategorized`-Zweig in `backend/app/routers/transactions.py` arbeitet mit
|
||||
genau so einer Unterabfrage.
|
||||
`tag_id`, `subscription_id`. Der Tag-Filter arbeitet mit einer Unterabfrage
|
||||
wie der `uncategorized`-Zweig daneben, nicht mit einem Join — sonst käme eine
|
||||
Buchung so oft zurück, wie sie Tags trägt.
|
||||
|
||||
Nötig für Stufe 2 — ohne das lädt das Telefon für jede Ansicht bis zu 5000
|
||||
Zeilen.
|
||||
Zwei Dinge, die die Tests festhalten (`backend/tests/test_buchungssuche.py`,
|
||||
7 Stück):
|
||||
- `%` und `_` im Suchtext sind **Zeichen, keine Platzhalter**. Sonst fände
|
||||
die Eingabe „50%" alles.
|
||||
- Ein fremder `tag_id` liefert **nichts**, nicht etwa alles. Der Filter
|
||||
schlägt auf `TransactionTag.owner_id` an.
|
||||
|
||||
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` (offen)
|
||||
Grenze: SQLite vergleicht bei LIKE nur ASCII ohne Rücksicht auf Gross- und
|
||||
Kleinschreibung. „Müller" findet „müller" nicht. Reicht, bis jemand es meldet.
|
||||
|
||||
Beide fehlen heute in der Antwort (`backend/app/routers/budgets.py`), obwohl
|
||||
das Modell sie führt und `PUT` sie zurückgibt. Ohne sie kann die App die
|
||||
Vorlage weder richtig anzeigen noch bearbeiten. Nötig für Stufe 3.
|
||||
Der lokale Name `q` im Router hiess vorher die Abfrage selbst; die heisst
|
||||
jetzt `abfrage`, damit der Query-Parameter `q` heissen kann.
|
||||
|
||||
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` ✅
|
||||
|
||||
Beide fehlten in der Antwort (`backend/app/routers/budgets.py`), obwohl das
|
||||
Modell sie führt und `PUT` sie zurückgibt. Jetzt stehen sie in jeder Zeile.
|
||||
Test: `test_vorlage_nennt_betriebsart_und_zuschlag` in `test_budget.py`.
|
||||
|
||||
Das war der stillere der beiden Fehler: Ein Client, der die Vorlage so liest,
|
||||
zeigt eine berechnete Zeile als feste **und schreibt sie beim nächsten
|
||||
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
|
||||
die Betriebsart aus der Monatsansicht nimmt.
|
||||
|
||||
### D — Zwei Kopier-Lücken im Budget (offen)
|
||||
|
||||
@@ -276,7 +293,7 @@ nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
|
||||
|
||||
**Stufe 2 — Lesen.** Dashboard (feste Ansicht: Kontostände, Budget-Fortschritt,
|
||||
letzte Buchungen, Einnahmen/Ausgaben), Budget-Monatsansicht, Buchungsliste,
|
||||
Abo-Liste. Dazu Backend-Ergänzungen **B** und **C**.
|
||||
Abo-Liste. Die Backend-Ergänzungen **B** und **C**, die sie braucht, sind da.
|
||||
|
||||
**Stufe 3 — Schreiben.** Buchungen anlegen und ändern, Budget bearbeiten
|
||||
(Monat und Vorlage, beide Betriebsarten `fest`/`berechnet`), Abos verwalten
|
||||
@@ -359,14 +376,16 @@ Leicht zu übersehen, das Backend macht es an mehreren Stellen:
|
||||
nicht zeigen kann. Das ganze Projekt ist so geschrieben; halte es durch.
|
||||
- **Neue Swift-Datei?** Danach `cd ios && xcodegen generate`. Die Ordner stehen
|
||||
einzeln in `project.yml`; ein neuer Ordner will dort eine Zeile.
|
||||
- **Tests**: `cd backend && ./.venv/Scripts/python.exe -m pytest tests/ -q`
|
||||
(Python 3.12 im venv; das System-Python kann nicht bauen). Dauert rund zwei
|
||||
Minuten — im Hintergrund laufen lassen.
|
||||
- **Vorbekannt rot**, nicht von dir verursacht:
|
||||
`test_mail_import.py::test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt`
|
||||
und `test_migrations.py::test_batch_migration_keeps_dependent_rows` (der
|
||||
scheitert an einem Umgebungsproblem, `WinError 10106` in einem Subprozess).
|
||||
Erwartung nach jedem Lauf: genau diese zwei, sonst nichts.
|
||||
- **Tests**: `cd backend && .venv/bin/python -m pytest tests/ -q` (auf Windows
|
||||
`./.venv/Scripts/python.exe`). Python 3.12 im venv; das System-Python kann
|
||||
nicht bauen. Dauert hier rund 45 Sekunden.
|
||||
- **Erwartung: alles grün**, 438 Tests. Die zwei Ausfälle, die hier früher als
|
||||
„vorbekannt rot" standen, waren ein Windows-Problem der alten Maschine
|
||||
(`WinError 10106` im Subprozess) und laufen auf diesem Rechner durch. Wer
|
||||
einen roten Test sieht, hat ihn verursacht.
|
||||
- **Fehlt dem venv ein Modul** (`ModuleNotFoundError`), einmal
|
||||
`.venv/bin/python -m pip install -r requirements.txt` — es war hier nicht
|
||||
auf dem Stand von `requirements.txt`.
|
||||
- **Heredocs in Bash scheitern** an den deutschen Anführungszeichen („ ").
|
||||
Für Dateien mit solchen Zeichen das Write-Werkzeug nehmen, nicht `cat <<EOF`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user