Files
moneyfyandClaude Opus 5 b586d27b77 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
2026-09-09 13:40:37 +02:00

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.")