mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
41
backend/app/api/routes/tags.py
Normal file
41
backend/app/api/routes/tags.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.tag import Tag
|
||||
from app.schemas.tag import TagCreate, TagRead
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagRead])
|
||||
async def list_tags(session: AsyncSession = Depends(get_session)) -> list[Tag]:
|
||||
result = await session.execute(select(Tag).order_by(Tag.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=TagRead, status_code=201)
|
||||
async def create_tag(payload: TagCreate, session: AsyncSession = Depends(get_session)) -> Tag:
|
||||
# Case-insensitive dedup — the frontend's tag picker creates tags
|
||||
# on the fly as the user types, so "Georgia" and "georgia" typed on two
|
||||
# different transactions should end up as the same tag, not two.
|
||||
name = payload.name.strip()
|
||||
existing = await session.execute(select(Tag).where(func.lower(Tag.name) == name.lower()))
|
||||
tag = existing.scalar_one_or_none()
|
||||
if tag is not None:
|
||||
return tag
|
||||
tag = Tag(name=name)
|
||||
session.add(tag)
|
||||
await session.commit()
|
||||
await session.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
tag = await session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
await session.delete(tag)
|
||||
await session.commit()
|
||||
@@ -9,12 +9,24 @@ from sqlalchemy.orm import selectinload
|
||||
from app.api.deps import get_session
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind, TransactionType
|
||||
from app.models.tag import Tag
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category))
|
||||
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category), selectinload(Transaction.tags))
|
||||
|
||||
|
||||
async def _resolve_tags(session: AsyncSession, tag_ids: list[int]) -> list[Tag]:
|
||||
if not tag_ids:
|
||||
return []
|
||||
result = await session.execute(select(Tag).where(Tag.id.in_(tag_ids)))
|
||||
tags = list(result.scalars().all())
|
||||
missing = set(tag_ids) - {tag.id for tag in tags}
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown tag id(s): {sorted(missing)}")
|
||||
return tags
|
||||
|
||||
_TYPE_TO_CATEGORY_KIND = {
|
||||
TransactionType.INCOME: CategoryKind.INCOME,
|
||||
@@ -50,6 +62,7 @@ async def list_transactions(
|
||||
end_date: date_ | None = Query(default=None),
|
||||
account_id: int | None = None,
|
||||
category_id: int | None = None,
|
||||
tag_id: int | None = None,
|
||||
type: TransactionType | None = None,
|
||||
search: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||
@@ -78,6 +91,9 @@ async def list_transactions(
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Transaction.category_id == category_id)
|
||||
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
||||
count_stmt = count_stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
||||
if type is not None:
|
||||
stmt = stmt.where(Transaction.type == type)
|
||||
count_stmt = count_stmt.where(Transaction.type == type)
|
||||
@@ -127,7 +143,9 @@ async def list_transaction_years(session: AsyncSession = Depends(get_session)) -
|
||||
@router.post("", response_model=TransactionRead, status_code=201)
|
||||
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||
transaction = Transaction(**payload.model_dump())
|
||||
fields = payload.model_dump(exclude={"tag_ids"})
|
||||
transaction = Transaction(**fields)
|
||||
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
||||
session.add(transaction)
|
||||
await session.commit()
|
||||
refreshed = await session.execute(
|
||||
@@ -140,15 +158,20 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession =
|
||||
async def update_transaction(
|
||||
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> Transaction:
|
||||
transaction = await session.get(Transaction, transaction_id)
|
||||
# Eager-loads tags — assigning transaction.tags below would otherwise lazy
|
||||
# -load the current collection first to diff against, which async
|
||||
# SQLAlchemy can't do outside an explicit await (MissingGreenlet).
|
||||
transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)])
|
||||
if transaction is None:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids"})
|
||||
effective_type = updates.get("type", transaction.type)
|
||||
effective_category_id = updates.get("category_id", transaction.category_id)
|
||||
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||
for field, value in updates.items():
|
||||
setattr(transaction, field, value)
|
||||
if payload.tag_ids is not None:
|
||||
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
||||
await session.commit()
|
||||
refreshed = await session.execute(
|
||||
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
||||
|
||||
Reference in New Issue
Block a user