- 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
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""Budgets und Budgetvorlagen."""
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Query, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.api.deps import DbSession
|
|
from app.core.errors import ConflictError, ValidationError
|
|
from app.models import Budget, BudgetTemplate, Category
|
|
from app.schemas.budget import (
|
|
BudgetCreate,
|
|
BudgetOut,
|
|
BudgetTemplateCreate,
|
|
BudgetTemplateOut,
|
|
BudgetTemplateUpdate,
|
|
BudgetUpdate,
|
|
)
|
|
from app.schemas.common import ErrorResponse, MessageResponse
|
|
from app.services.crud import apply_updates, get_or_404
|
|
|
|
router = APIRouter(prefix="/budgets", tags=["budgets"])
|
|
|
|
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=list[BudgetOut],
|
|
summary="Budgets auflisten",
|
|
description="Ohne `month` werden alle Monate geliefert.",
|
|
)
|
|
async def list_budgets(
|
|
session: DbSession,
|
|
month: date | None = Query(default=None, description="Beliebiger Tag im gesuchten Monat."),
|
|
category_id: int | None = Query(default=None),
|
|
) -> list[Budget]:
|
|
stmt = select(Budget).order_by(Budget.period_month.desc(), Budget.category_id)
|
|
if month is not None:
|
|
stmt = stmt.where(Budget.period_month == month.replace(day=1))
|
|
if category_id is not None:
|
|
stmt = stmt.where(Budget.category_id == category_id)
|
|
return list((await session.execute(stmt)).scalars().all())
|
|
|
|
|
|
@router.post(
|
|
"", response_model=BudgetOut, status_code=status.HTTP_201_CREATED, summary="Budget anlegen"
|
|
)
|
|
async def create_budget(payload: BudgetCreate, session: DbSession) -> Budget:
|
|
await get_or_404(session, Category, payload.category_id)
|
|
budget = Budget(**payload.model_dump())
|
|
session.add(budget)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError as exc:
|
|
await session.rollback()
|
|
raise ConflictError(
|
|
"Für diese Kategorie und diesen Monat gibt es bereits ein Budget."
|
|
) from exc
|
|
await session.refresh(budget)
|
|
return budget
|
|
|
|
|
|
@router.patch(
|
|
"/{budget_id}", response_model=BudgetOut, responses=NOT_FOUND, summary="Budget ändern"
|
|
)
|
|
async def update_budget(budget_id: int, payload: BudgetUpdate, session: DbSession) -> Budget:
|
|
budget = await get_or_404(session, Budget, budget_id)
|
|
apply_updates(budget, payload)
|
|
await session.commit()
|
|
await session.refresh(budget)
|
|
return budget
|
|
|
|
|
|
@router.delete(
|
|
"/{budget_id}",
|
|
response_model=MessageResponse,
|
|
responses=NOT_FOUND,
|
|
summary="Budget löschen",
|
|
)
|
|
async def delete_budget(budget_id: int, session: DbSession) -> MessageResponse:
|
|
budget = await get_or_404(session, Budget, budget_id)
|
|
await session.delete(budget)
|
|
await session.commit()
|
|
return MessageResponse(detail="Budget gelöscht.")
|
|
|
|
|
|
# --- Vorlagen ------------------------------------------------------------------
|
|
|
|
templates = APIRouter(prefix="/budget-templates", tags=["budgets"])
|
|
|
|
|
|
@templates.get(
|
|
"",
|
|
response_model=list[BudgetTemplateOut],
|
|
summary="Budgetvorlagen auflisten",
|
|
description="Vorlagen gelten ab `valid_from` dauerhaft, sodass nicht jeder "
|
|
"Monat einzeln gepflegt werden muss.",
|
|
)
|
|
async def list_templates(
|
|
session: DbSession, category_id: int | None = Query(default=None)
|
|
) -> list[BudgetTemplate]:
|
|
stmt = select(BudgetTemplate).order_by(
|
|
BudgetTemplate.category_id, BudgetTemplate.valid_from.desc()
|
|
)
|
|
if category_id is not None:
|
|
stmt = stmt.where(BudgetTemplate.category_id == category_id)
|
|
return list((await session.execute(stmt)).scalars().all())
|
|
|
|
|
|
@templates.post(
|
|
"",
|
|
response_model=BudgetTemplateOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Budgetvorlage anlegen",
|
|
)
|
|
async def create_template(payload: BudgetTemplateCreate, session: DbSession) -> BudgetTemplate:
|
|
await get_or_404(session, Category, payload.category_id)
|
|
if payload.valid_until is not None and payload.valid_until < payload.valid_from:
|
|
raise ValidationError(
|
|
"'valid_until' darf nicht vor 'valid_from' liegen.", code="invalid_date_range"
|
|
)
|
|
|
|
template = BudgetTemplate(**payload.model_dump())
|
|
session.add(template)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError as exc:
|
|
await session.rollback()
|
|
raise ConflictError(
|
|
"Für diese Kategorie gibt es ab diesem Monat bereits eine Vorlage."
|
|
) from exc
|
|
await session.refresh(template)
|
|
return template
|
|
|
|
|
|
@templates.patch(
|
|
"/{template_id}",
|
|
response_model=BudgetTemplateOut,
|
|
responses=NOT_FOUND,
|
|
summary="Budgetvorlage ändern",
|
|
)
|
|
async def update_template(
|
|
template_id: int, payload: BudgetTemplateUpdate, session: DbSession
|
|
) -> BudgetTemplate:
|
|
template = await get_or_404(session, BudgetTemplate, template_id)
|
|
if payload.valid_until is not None and payload.valid_until < template.valid_from:
|
|
raise ValidationError(
|
|
"'valid_until' darf nicht vor 'valid_from' liegen.", code="invalid_date_range"
|
|
)
|
|
apply_updates(template, payload)
|
|
await session.commit()
|
|
await session.refresh(template)
|
|
return template
|
|
|
|
|
|
@templates.delete(
|
|
"/{template_id}",
|
|
response_model=MessageResponse,
|
|
responses=NOT_FOUND,
|
|
summary="Budgetvorlage löschen",
|
|
)
|
|
async def delete_template(template_id: int, session: DbSession) -> MessageResponse:
|
|
template = await get_or_404(session, BudgetTemplate, template_id)
|
|
await session.delete(template)
|
|
await session.commit()
|
|
return MessageResponse(detail="Budgetvorlage gelöscht.")
|