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,168 @@
|
||||
"""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.")
|
||||
Reference in New Issue
Block a user