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