feat: add free-form tags on transactions

Third of the four ideas picked from the Maybe/Sure feature audit. Tags are
orthogonal to categories (category = what kind of spend, tag = which
event/project) — many-to-many, created inline while typing in the
transaction form, filterable from the Transactions page.

Also adds tests/test_backup.py, the first test coverage the backup
export/import path has ever had, and fixes a MissingGreenlet bug it
surfaced in restore_backup's tag-wiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:38:04 +03:00
parent 09a0e383e0
commit 94782ee073
24 changed files with 585 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ from app.models.category import Category
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.tag import Tag
from app.models.transaction import Transaction
__all__ = [
@@ -17,5 +18,6 @@ __all__ = [
"Goal",
"GoalContribution",
"RecurringTransaction",
"Tag",
"Transaction",
]

26
backend/app/models/tag.py Normal file
View File

@@ -0,0 +1,26 @@
"""A free-form, reusable label on transactions — orthogonal to Category:
a category answers "what kind of spend is this", a tag answers "which
event/project does it belong to" (e.g. "trip:georgia", "tax-deductible").
A transaction can carry any number of tags, a tag can be reused across any
number of transactions — see transaction_tags below.
"""
from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
transaction_tags = Table(
"transaction_tags",
Base.metadata,
Column("transaction_id", ForeignKey("transactions.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
transactions: Mapped[list["Transaction"]] = relationship(secondary=transaction_tags, back_populates="tags")

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import TransactionType
from app.models.mixins import TimestampMixin
from app.models.tag import transaction_tags
class Transaction(Base, TimestampMixin):
@@ -33,3 +34,4 @@ class Transaction(Base, TimestampMixin):
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship(back_populates="transactions")
tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, back_populates="transactions")