feat: Buchungen lassen sich mit eigenen, farbigen Tags versehen und danach filtern

Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles,
was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte
bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann.

Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt
werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die
Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-19 07:16:17 +02:00
co-authored by Claude Opus 5
parent a9ea0432d8
commit 50fc0e3353
16 changed files with 961 additions and 11 deletions
+182
View File
@@ -0,0 +1,182 @@
"""Tags: frei vergebene Etiketten fuer Buchungen, mit eigener Farbe.
Die Kategorie beantwortet "wofuer war das Geld". Alles, was quer dazu liegt –
"Urlaub Island", "steuerlich absetzbar", "zurueckfordern" –, passt in keine
Kategorie, weil eine Buchung davon mehrere auf einmal sein kann. Genau das
koennen Tags, und genau daran haengt hier alles: mehrere je Buchung, frei
benannt, und beim Abnehmen darf auch keiner uebrig bleiben.
Die Datenbank lebt ueber den ganzen Testlauf. Namen und Betraege deshalb
eindeutig wuerfeln, sonst stolpert man ueber die Daten anderer Tests.
"""
import json
import secrets
from conftest import login_ready, new_user
def _tag(admin, name=None, color="#3f51b5"):
r = admin.post("/api/tags", json={
"name": name or f"Tag {secrets.token_hex(4)}", "color": color})
assert r.status_code == 201, r.text
return r.json()
def _konto(admin, name=None):
return admin.post("/api/accounts", json={
"name": name or f"Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
def _buchung(admin, konto, cents=None, payee="Testkauf"):
return admin.post("/api/transactions", json={
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
"amount_cents": cents or (100 + secrets.randbelow(9000)),
"payee": payee}).json()
def test_tag_anlegen_umbenennen_umfaerben_loeschen(admin):
name = f"Urlaub {secrets.token_hex(4)}"
tag = _tag(admin, name, "#2a78d6")
assert tag["name"] == name and tag["color"] == "#2a78d6"
neu = f"Reise {secrets.token_hex(4)}"
r = admin.patch(f"/api/tags/{tag['id']}", json={"name": neu, "color": "#eb6834"})
assert r.status_code == 200, r.text
assert r.json()["name"] == neu and r.json()["color"] == "#eb6834"
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
assert tag["id"] not in {x["id"] for x in admin.get("/api/tags").json()}
def test_derselbe_name_zweimal_wird_abgelehnt(admin):
"""Zwei gleich heissende Tags liessen sich in der Auswahl nicht unterscheiden."""
name = f"Doppelt {secrets.token_hex(4)}"
_tag(admin, name)
r = admin.post("/api/tags", json={"name": name, "color": "#000000"})
assert r.status_code == 409, r.text
# Auch beim Umbenennen auf einen vergebenen Namen.
anderer = _tag(admin)
assert admin.patch(f"/api/tags/{anderer['id']}", json={"name": name}).status_code == 409
# Auf den EIGENEN Namen umbenennen bleibt erlaubt.
assert admin.patch(f"/api/tags/{anderer['id']}",
json={"name": anderer["name"], "color": "#111111"}).status_code == 200
def test_leerer_name_wird_abgelehnt(admin):
assert admin.post("/api/tags", json={"name": " "}).status_code == 400
def test_eine_buchung_traegt_mehrere_tags(admin):
konto = _konto(admin)
a, b = _tag(admin), _tag(admin)
txn = _buchung(admin, konto)
assert txn["tag_ids"] == []
r = admin.patch(f"/api/transactions/{txn['id']}",
json={"tag_ids": [a["id"], b["id"]]})
assert r.status_code == 200, r.text
assert sorted(r.json()["tag_ids"]) == sorted([a["id"], b["id"]])
# Auch beim Einzelabruf und in der Liste.
assert sorted(admin.get(f"/api/transactions/{txn['id']}").json()["tag_ids"]) \
== sorted([a["id"], b["id"]])
aus_liste = next(t for t in admin.get("/api/transactions").json() if t["id"] == txn["id"])
assert sorted(aus_liste["tag_ids"]) == sorted([a["id"], b["id"]])
def test_mitgeschickt_heisst_ersetzen_weggelassen_heisst_unberuehrt(admin):
"""Die Oberflaeche schickt immer den vollen Stand. Eine leere Liste muss
deshalb alle abnehmen - sonst wuerde man einen Tag nie wieder los. Wer
``tag_ids`` gar nicht mitschickt (etwa beim Aendern des Betrags), darf die
Tags aber nicht verlieren."""
konto = _konto(admin)
a, b = _tag(admin), _tag(admin)
txn = _buchung(admin, konto)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [a["id"], b["id"]]})
# Ersetzen durch genau einen.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [b["id"]]})
assert r.json()["tag_ids"] == [b["id"]]
# Etwas anderes aendern - die Tags bleiben.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"amount_cents": 4242})
assert r.json()["tag_ids"] == [b["id"]], "ohne tag_ids darf sich nichts aendern"
# Leere Liste nimmt alle ab.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": []})
assert r.json()["tag_ids"] == []
def test_tag_loeschen_laesst_die_buchung_stehen(admin):
"""Es verschwindet das Etikett, nie eine Buchung."""
konto = _konto(admin)
tag = _tag(admin)
txn = _buchung(admin, konto, cents=777)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
danach = admin.get(f"/api/transactions/{txn['id']}").json()
assert danach["amount_cents"] == 777
assert danach["tag_ids"] == []
def test_buchung_loeschen_laesst_den_tag_stehen(admin):
konto = _konto(admin)
tag = _tag(admin)
txn = _buchung(admin, konto)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
assert admin.delete(f"/api/transactions/{txn['id']}").status_code == 204
assert tag["id"] in {x["id"] for x in admin.get("/api/tags").json()}
def test_fremder_tag_bleibt_unerreichbar(admin):
"""Mandanten-Trennung: 404, nicht 403 - wie ueberall im Projekt."""
fremd = _tag(admin)
anderer, _ = login_ready(*new_user(admin))
assert fremd["id"] not in {x["id"] for x in anderer.get("/api/tags").json()}
assert anderer.patch(f"/api/tags/{fremd['id']}", json={"name": "geklaut"}).status_code == 404
assert anderer.delete(f"/api/tags/{fremd['id']}").status_code == 404
# Und er laesst sich auch nicht an die eigene Buchung haengen.
konto = _konto(anderer)
txn = _buchung(anderer, konto)
assert anderer.patch(f"/api/transactions/{txn['id']}",
json={"tag_ids": [fremd["id"]]}).status_code == 404
assert anderer.get(f"/api/transactions/{txn['id']}").json()["tag_ids"] == []
def test_tags_ueberleben_das_backup(admin):
konto = _konto(admin)
name = f"Backup {secrets.token_hex(4)}"
tag = _tag(admin, name, "#1baf7a")
# Der Text muss eindeutig sein, nicht der Betrag: Nach dem Einspielen sind
# die IDs neu vergeben, und andere Tests legen laufend Buchungen an.
text = f"Tagtraeger {secrets.token_hex(4)}"
txn = _buchung(admin, konto, cents=5151, payee=text)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
daten = json.loads(admin.get("/api/export/backup.json").content)
gesichert = [x for x in daten["data"]["tags"] if x["name"] == name]
assert gesichert and gesichert[0]["color"] == "#1baf7a"
assert daten["data"]["transaction_tags"], "die Zuordnung muss mitgesichert sein"
admin.delete(f"/api/tags/{tag['id']}")
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
"application/json")},
data={"mode": "replace"})
assert r.status_code == 200, r.text
zurueck = [x for x in admin.get("/api/tags").json() if x["name"] == name]
assert zurueck and zurueck[0]["color"] == "#1baf7a"
wieder = [t for t in admin.get("/api/transactions").json() if t["payee"] == text]
assert wieder, "die Buchung muss nach dem Einspielen wieder da sein"
assert wieder[0]["tag_ids"] == [zurueck[0]["id"]], \
"und ihr Tag muss wieder an ihr haengen"