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,152 @@
|
||||
"""Fälligkeiten über alle Posten hinweg: abrufen, bestätigen, auslassen."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.api.routes.recurrences import to_occurrence_out
|
||||
from app.core.clock import add_months, today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.schemas.common import ErrorResponse, MessageResponse
|
||||
from app.schemas.occurrence import (
|
||||
OccurrenceConfirm,
|
||||
OccurrenceOut,
|
||||
OccurrenceReset,
|
||||
OccurrenceSkip,
|
||||
)
|
||||
from app.services import occurrences as service
|
||||
|
||||
router = APIRouter(prefix="/occurrences", tags=["occurrences"])
|
||||
|
||||
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[OccurrenceOut],
|
||||
summary="Fälligkeiten im Zeitraum",
|
||||
description="Expandiert alle passenden Posten und überlagert sie mit den "
|
||||
"erfassten Abweichungen. Ohne Angabe umfasst das Fenster die nächsten drei Monate.",
|
||||
)
|
||||
async def list_occurrences(
|
||||
session: DbSession,
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
kind: EntryKind | None = Query(default=None),
|
||||
category_id: int | None = Query(default=None),
|
||||
account_id: int | None = Query(default=None),
|
||||
recurrence_id: int | None = Query(default=None),
|
||||
status_filter: OccurrenceStatus | None = Query(
|
||||
default=None, alias="status", description="Nach Status filtern."
|
||||
),
|
||||
include_inactive: bool = Query(
|
||||
default=False, description="Auch deaktivierte Posten einbeziehen."
|
||||
),
|
||||
by_due_date: bool = Query(
|
||||
default=True,
|
||||
description="True gruppiert nach dem tatsächlichen Zahltag, False nach dem "
|
||||
"nominalen Datum.",
|
||||
),
|
||||
) -> list[OccurrenceOut]:
|
||||
start = date_from or today()
|
||||
end = date_to or add_months(start, 3)
|
||||
if end < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
items = await service.due_items(
|
||||
session,
|
||||
start,
|
||||
end,
|
||||
by_due_date=by_due_date,
|
||||
kind=kind,
|
||||
only_active=not include_inactive,
|
||||
category_id=category_id,
|
||||
account_id=account_id,
|
||||
recurrence_id=recurrence_id,
|
||||
)
|
||||
if status_filter is not None:
|
||||
items = [item for item in items if item.planned.status is status_filter]
|
||||
|
||||
return [to_occurrence_out(item.planned, item.recurrence) for item in items]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/confirm",
|
||||
response_model=OccurrenceOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Fälligkeit bestätigen",
|
||||
description="Schlüssel ist das nominale Datum. Ohne `actual_amount` gilt der Sollbetrag.",
|
||||
)
|
||||
async def confirm_occurrence(payload: OccurrenceConfirm, session: DbSession) -> OccurrenceOut:
|
||||
await service.confirm(
|
||||
session,
|
||||
payload.recurrence_id,
|
||||
payload.occurrence_date,
|
||||
actual_amount=payload.actual_amount,
|
||||
actual_date=payload.actual_date,
|
||||
account_id=payload.account_id,
|
||||
note=payload.note,
|
||||
)
|
||||
await session.commit()
|
||||
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skip",
|
||||
response_model=OccurrenceOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Fälligkeit auslassen",
|
||||
description="Die Fälligkeit zählt danach in keiner Auswertung mehr mit.",
|
||||
)
|
||||
async def skip_occurrence(payload: OccurrenceSkip, session: DbSession) -> OccurrenceOut:
|
||||
await service.skip(session, payload.recurrence_id, payload.occurrence_date, note=payload.note)
|
||||
await session.commit()
|
||||
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reset",
|
||||
response_model=OccurrenceOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Bestätigung zurücknehmen",
|
||||
description="Löscht die erfasste Abweichung; die Fälligkeit gilt wieder als geplant.",
|
||||
)
|
||||
async def reset_occurrence(payload: OccurrenceReset, session: DbSession) -> OccurrenceOut:
|
||||
await service.reset(session, payload.recurrence_id, payload.occurrence_date)
|
||||
await session.commit()
|
||||
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{recurrence_id}/{occurrence_date}",
|
||||
response_model=MessageResponse,
|
||||
responses=NOT_FOUND,
|
||||
summary="Erfasste Abweichung löschen",
|
||||
)
|
||||
async def delete_occurrence(
|
||||
recurrence_id: int, occurrence_date: date, session: DbSession
|
||||
) -> MessageResponse:
|
||||
await service.reset(session, recurrence_id, occurrence_date)
|
||||
await session.commit()
|
||||
return MessageResponse(detail="Erfasste Abweichung gelöscht.")
|
||||
|
||||
|
||||
async def _single(session: DbSession, recurrence_id: int, occurrence_date: date) -> OccurrenceOut:
|
||||
"""Liest genau eine Fälligkeit nach einer Änderung frisch aus."""
|
||||
recurrence = await service.get_recurrence(session, recurrence_id)
|
||||
items = await service.due_items(
|
||||
session,
|
||||
occurrence_date,
|
||||
occurrence_date,
|
||||
by_due_date=False,
|
||||
only_active=False,
|
||||
recurrence_id=recurrence_id,
|
||||
)
|
||||
if not items:
|
||||
raise ValidationError(
|
||||
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
|
||||
code="occurrence_not_due",
|
||||
)
|
||||
return to_occurrence_out(items[0].planned, items[0].recurrence)
|
||||
Reference in New Issue
Block a user