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
+62 -6
View File
@@ -19,7 +19,9 @@ from ..models import (
Category,
InboxItem,
InboxStatus,
Tag,
Transaction,
TransactionTag,
TransferLink,
TxnType,
User,
@@ -82,7 +84,8 @@ def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> s
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
herkunft: dict[int, str] | None = None, start_tag: int = 1) -> TransactionOut:
herkunft: dict[int, str] | None = None, start_tag: int = 1,
tags: dict[int, list[int]] | None = None) -> TransactionOut:
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
if base == "EUR":
amount_base = txn.amount_eur_cents
@@ -113,9 +116,50 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
payee=txn.payee,
description=txn.description,
notes=txn.notes,
tag_ids=(tags or {}).get(txn.id, []),
)
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
N+1, das die vier Nachbarn hier schon vermeiden.
"""
ids = [t.id for t in txns]
if not ids:
return {}
nach: dict[int, list[int]] = {}
for txn_id, tag_id in db.query(
TransactionTag.transaction_id, TransactionTag.tag_id
).filter(TransactionTag.owner_id == user.id,
TransactionTag.transaction_id.in_(ids)):
nach.setdefault(txn_id, []).append(tag_id)
return nach
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
"""Die Tags einer Buchung auf genau diese Liste bringen.
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
"""
gewuenscht = {int(i) for i in tag_ids}
for tag_id in gewuenscht:
get_owned(db, Tag, tag_id, user)
vorhanden = {
z.tag_id: z for z in db.query(TransactionTag).filter(
TransactionTag.owner_id == user.id,
TransactionTag.transaction_id == txn.id)
}
for tag_id, zeile in vorhanden.items():
if tag_id not in gewuenscht:
db.delete(zeile)
for tag_id in gewuenscht - set(vorhanden):
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
def _currency_map(db: Session, user: User) -> dict[int, str]:
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
return {row[0]: row[1] for row in rows}
@@ -184,7 +228,8 @@ def list_transactions(
ccy = _currency_map(db, user)
woher = _herkunft(db, user, rows)
tag = budget.start_tag(db, user)
return [to_out(db, t, base, ccy, woher, tag) for t in rows]
etiketten = _tags(db, user, rows)
return [to_out(db, t, base, ccy, woher, tag, etiketten) for t in rows]
@router.get("/dubletten")
@@ -332,10 +377,14 @@ def create_transaction(
)
buchung.setze_betraege(db, txn, from_acc, to_acc)
db.add(txn)
if payload.tag_ids:
db.flush() # erst mit id laesst sich das Etikett haengen
_setze_tags(db, user, txn, payload.tag_ids)
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.get("/{txn_id}", response_model=TransactionOut)
@@ -344,7 +393,8 @@ def get_transaction(
):
txn = get_owned(db, Transaction, txn_id, user)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.patch("/{txn_id}", response_model=TransactionOut)
@@ -390,11 +440,16 @@ def update_transaction(
else:
txn.to_account_id = None
buchung.setze_betraege(db, txn, from_acc, to_acc)
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
# weggelassen heisst unberuehrt lassen.
if data.get("tag_ids") is not None:
_setze_tags(db, user, txn, data["tag_ids"])
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
class TransactionTransferIn(BaseModel):
@@ -426,7 +481,8 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)