Files
moneyfy/backend/app/api/routes/savings_goals.py
T
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

81 lines
2.7 KiB
Python

"""Sparziele."""
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
from app.models import SavingsGoal
from app.schemas.budget import SavingsGoalCreate, SavingsGoalOut, SavingsGoalUpdate
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.crud import apply_updates, get_or_404
router = APIRouter(prefix="/savings-goals", tags=["savings-goals"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get("", response_model=list[SavingsGoalOut], summary="Sparziele auflisten")
async def list_goals(
session: DbSession,
include_archived: bool = Query(default=False),
) -> list[SavingsGoal]:
stmt = select(SavingsGoal).order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
if not include_archived:
stmt = stmt.where(SavingsGoal.is_archived.is_(False))
return list((await session.execute(stmt)).scalars().all())
@router.post(
"",
response_model=SavingsGoalOut,
status_code=status.HTTP_201_CREATED,
summary="Sparziel anlegen",
)
async def create_goal(payload: SavingsGoalCreate, session: DbSession) -> SavingsGoal:
goal = SavingsGoal(**payload.model_dump())
session.add(goal)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(f"Das Sparziel '{payload.name}' existiert bereits.") from exc
await session.refresh(goal)
return goal
@router.get(
"/{goal_id}", response_model=SavingsGoalOut, responses=NOT_FOUND, summary="Sparziel lesen"
)
async def read_goal(goal_id: int, session: DbSession) -> SavingsGoal:
return await get_or_404(session, SavingsGoal, goal_id)
@router.patch(
"/{goal_id}", response_model=SavingsGoalOut, responses=NOT_FOUND, summary="Sparziel ändern"
)
async def update_goal(goal_id: int, payload: SavingsGoalUpdate, session: DbSession) -> SavingsGoal:
goal = await get_or_404(session, SavingsGoal, goal_id)
apply_updates(goal, payload)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Ein Sparziel mit diesem Namen existiert bereits.") from exc
await session.refresh(goal)
return goal
@router.delete(
"/{goal_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Sparziel löschen",
)
async def delete_goal(goal_id: int, session: DbSession) -> MessageResponse:
goal = await get_or_404(session, SavingsGoal, goal_id)
await session.delete(goal)
await session.commit()
return MessageResponse(detail="Sparziel gelöscht.")