feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Einmalige Buchungen."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import Category, Transaction
|
||||
from app.models.enums import EntryKind
|
||||
from app.schemas.common import ErrorResponse, MessageResponse, Page
|
||||
from app.schemas.transaction import TransactionCreate, TransactionOut, TransactionUpdate
|
||||
from app.services.crud import apply_updates, get_or_404
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
async def _check_category(session: DbSession, category_id: int, kind: EntryKind) -> None:
|
||||
category = await get_or_404(session, Category, category_id)
|
||||
if category.kind is not kind:
|
||||
richtung = "Einkünfte" if kind is EntryKind.INCOME else "Ausgaben"
|
||||
raise ValidationError(
|
||||
f"Die Kategorie '{category.name}' ist nicht für {richtung} vorgesehen.",
|
||||
code="category_kind_mismatch",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=Page[TransactionOut],
|
||||
summary="Buchungen auflisten",
|
||||
description="Neueste zuerst. Alle Filter sind kombinierbar.",
|
||||
)
|
||||
async def list_transactions(
|
||||
session: DbSession,
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
kind: EntryKind | None = Query(default=None),
|
||||
category_id: int | None = Query(default=None),
|
||||
account_id: int | None = Query(default=None),
|
||||
merchant_id: int | None = Query(default=None),
|
||||
q: str | None = Query(default=None, description="Suche in Titel und Notiz."),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> Page[TransactionOut]:
|
||||
if date_from and date_to and date_to < date_from:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
stmt = select(Transaction).order_by(Transaction.booking_date.desc(), Transaction.id.desc())
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Transaction.booking_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Transaction.booking_date <= date_to)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Transaction.kind == kind)
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Transaction.category_id == category_id)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(Transaction.account_id == account_id)
|
||||
if merchant_id is not None:
|
||||
stmt = stmt.where(Transaction.merchant_id == merchant_id)
|
||||
if q:
|
||||
pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(Transaction.title.ilike(pattern), Transaction.note.ilike(pattern)))
|
||||
|
||||
total = (
|
||||
await session.execute(select(func.count()).select_from(stmt.order_by(None).subquery()))
|
||||
).scalar_one()
|
||||
items = (await session.execute(stmt.limit(limit).offset(offset))).scalars().all()
|
||||
return Page[TransactionOut](
|
||||
items=[TransactionOut.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TransactionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Buchung anlegen",
|
||||
)
|
||||
async def create_transaction(payload: TransactionCreate, session: DbSession) -> Transaction:
|
||||
await _check_category(session, payload.category_id, payload.kind)
|
||||
transaction = Transaction(**payload.model_dump())
|
||||
session.add(transaction)
|
||||
await session.commit()
|
||||
await session.refresh(transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Buchung lesen",
|
||||
)
|
||||
async def read_transaction(transaction_id: int, session: DbSession) -> Transaction:
|
||||
return await get_or_404(session, Transaction, transaction_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Buchung ändern",
|
||||
)
|
||||
async def update_transaction(
|
||||
transaction_id: int, payload: TransactionUpdate, session: DbSession
|
||||
) -> Transaction:
|
||||
transaction = await get_or_404(session, Transaction, transaction_id)
|
||||
if payload.category_id is not None or payload.kind is not None:
|
||||
await _check_category(
|
||||
session,
|
||||
payload.category_id or transaction.category_id,
|
||||
payload.kind or transaction.kind,
|
||||
)
|
||||
apply_updates(transaction, payload)
|
||||
await session.commit()
|
||||
await session.refresh(transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{transaction_id}",
|
||||
response_model=MessageResponse,
|
||||
responses=NOT_FOUND,
|
||||
summary="Buchung löschen",
|
||||
)
|
||||
async def delete_transaction(transaction_id: int, session: DbSession) -> MessageResponse:
|
||||
transaction = await get_or_404(session, Transaction, transaction_id)
|
||||
await session.delete(transaction)
|
||||
await session.commit()
|
||||
return MessageResponse(detail="Buchung gelöscht.")
|
||||
Reference in New Issue
Block a user