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:
co-authored by
Claude Opus 5
parent
a9ea0432d8
commit
50fc0e3353
@@ -45,6 +45,7 @@ from .routers import (
|
||||
rates,
|
||||
settings as settings_router,
|
||||
subscriptions,
|
||||
tags,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
@@ -214,6 +215,7 @@ api.include_router(settings_router.router)
|
||||
api.include_router(policy.router)
|
||||
api.include_router(accounts.router)
|
||||
api.include_router(categories.router)
|
||||
api.include_router(tags.router)
|
||||
api.include_router(transactions.router)
|
||||
api.include_router(budgets.router)
|
||||
api.include_router(dashboard.router)
|
||||
|
||||
@@ -1281,3 +1281,61 @@ class BankAccountLink(Base):
|
||||
except ValueError:
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
|
||||
|
||||
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
|
||||
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
|
||||
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
|
||||
keine Pflicht, genau eines zu waehlen.
|
||||
|
||||
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
|
||||
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
|
||||
"""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||
)
|
||||
|
||||
|
||||
class TransactionTag(Base):
|
||||
"""Welcher Tag haengt an welcher Buchung.
|
||||
|
||||
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
|
||||
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
|
||||
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
|
||||
|
||||
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
|
||||
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
|
||||
Buchung.
|
||||
"""
|
||||
|
||||
__tablename__ = "transaction_tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
transaction_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tags: frei vergebene Etiketten fuer Buchungen. Alles owner-gescoped.
|
||||
|
||||
Neben der Kategorie, nicht statt ihr. Die Kategorie sagt, wofuer das Geld war;
|
||||
ein Tag alles, was quer dazu liegt – und davon duerfen mehrere an einer Buchung
|
||||
haengen.
|
||||
|
||||
Die Zuordnung selbst wird hier NICHT gesetzt, sondern an der Buchung
|
||||
(``PATCH /transactions/{id}`` mit ``tag_ids``). Sonst gaebe es zwei Wege, eine
|
||||
Buchung zu aendern, und zwei Stellen, an denen man es vergessen kann.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, get_owned
|
||||
from ..models import Tag, User
|
||||
from ..schemas import TagCreate, TagOut, TagUpdate
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
def _name_frei(db: Session, user: User, name: str, ausser_id: int | None = None) -> None:
|
||||
"""Denselben Namen zweimal gaebe es in der Liste nicht zu unterscheiden."""
|
||||
q = db.query(Tag.id).filter(Tag.owner_id == user.id, Tag.name == name)
|
||||
if ausser_id is not None:
|
||||
q = q.filter(Tag.id != ausser_id)
|
||||
if q.first() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Den Tag „{name}“ gibt es schon.")
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagOut])
|
||||
def list_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
# Alphabetisch: In einer Auswahlliste sucht man nach dem Namen, nicht
|
||||
# danach, wann der Tag entstanden ist.
|
||||
return (
|
||||
db.query(Tag).filter(Tag.owner_id == user.id).order_by(Tag.name).all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TagOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_tag(payload: TagCreate, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Leerer Name")
|
||||
_name_frei(db, user, name)
|
||||
tag = Tag(owner_id=user.id, name=name, color=payload.color)
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.patch("/{tag_id}", response_model=TagOut)
|
||||
def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
tag = get_owned(db, Tag, tag_id, user)
|
||||
daten = payload.model_dump(exclude_unset=True)
|
||||
if "name" in daten and daten["name"] is not None:
|
||||
name = daten["name"].strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Leerer Name")
|
||||
_name_frei(db, user, name, ausser_id=tag.id)
|
||||
tag.name = name
|
||||
if daten.get("color") is not None:
|
||||
tag.color = daten["color"]
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_tag(tag_id: int, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
"""Den Tag loeschen. Die Buchungen bleiben – es faellt nur das Etikett ab
|
||||
(ON DELETE CASCADE auf ``transaction_tags``)."""
|
||||
tag = get_owned(db, Tag, tag_id, user)
|
||||
db.delete(tag)
|
||||
db.commit()
|
||||
@@ -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)
|
||||
|
||||
@@ -346,6 +346,28 @@ class SubscriptionListOut(BaseModel):
|
||||
summary: SubscriptionSummary
|
||||
|
||||
|
||||
# --- Tags ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||
color: str = "#6b7480"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
color: str | None = None
|
||||
|
||||
|
||||
# --- Kategorien ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -433,6 +455,9 @@ class TransactionCreate(BaseModel):
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
# Beim Anlegen gleich mitgeben, sonst muesste man die Buchung direkt
|
||||
# danach noch einmal oeffnen, nur um sie zu etikettieren.
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class TransactionUpdate(BaseModel):
|
||||
@@ -452,6 +477,10 @@ class TransactionUpdate(BaseModel):
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
# Die Tags dieser Buchung, vollstaendig. Mitgeschickt heisst ERSETZEN –
|
||||
# eine leere Liste nimmt alle weg. Weggelassen laesst sie unberuehrt;
|
||||
# darum haengt hier ``exclude_unset`` im Router dran.
|
||||
tag_ids: list[int] | None = None
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
@@ -486,6 +515,11 @@ class TransactionOut(BaseModel):
|
||||
payee: str | None
|
||||
description: str | None
|
||||
notes: str | None
|
||||
# Die Tags dieser Buchung. Anders als die Uebertrags-Verknuepfung stehen
|
||||
# sie IM Schema und nicht in einem eigenen Endpunkt: Jede Zeile der
|
||||
# Buchungsliste zeigt und filtert nach ihnen, ein zweiter Abruf wuerde im
|
||||
# Browser doch nur wieder zusammengefuegt.
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class RateStatus(BaseModel):
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tags fuer Buchungen.
|
||||
|
||||
Die Kategorie beantwortet „wofuer war das Geld". Alles, was quer dazu liegt,
|
||||
hatte bisher keinen Platz: „Urlaub Island", „steuerlich absetzbar",
|
||||
„zurueckfordern". Dafuer gibt es jetzt frei vergebene Etiketten mit eigener
|
||||
Farbe, mehrere je Buchung.
|
||||
|
||||
Zwei neue Tabellen, keine bestehende wird angefasst – ``create_table`` ist
|
||||
deshalb unbedenklich. Die Warnung gegen ``batch_alter_table`` aus 0034-0037
|
||||
betrifft den UMBAU vorhandener Tabellen, der ueber ON DELETE CASCADE haengende
|
||||
Zeilen mitrisse (tests/test_migrations.py).
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0038"
|
||||
down_revision = "0037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=60), nullable=False),
|
||||
sa.Column("color", sa.String(length=9), nullable=False,
|
||||
server_default="#6b7480"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||
)
|
||||
op.create_index("ix_tags_owner_id", "tags", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"transaction_tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("transaction_id", sa.Integer(),
|
||||
sa.ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("tag_id", sa.Integer(),
|
||||
sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||
)
|
||||
op.create_index("ix_transaction_tags_owner_id", "transaction_tags", ["owner_id"])
|
||||
op.create_index("ix_transaction_tags_transaction_id", "transaction_tags",
|
||||
["transaction_id"])
|
||||
op.create_index("ix_transaction_tags_tag_id", "transaction_tags", ["tag_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("transaction_tags")
|
||||
op.drop_table("tags")
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user