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,118 @@
|
||||
"""Firmen und Zahlungsempfänger."""
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.errors import ConflictError
|
||||
from app.models import Merchant, Recurrence, Transaction
|
||||
from app.models.enums import LogoStatus
|
||||
from app.schemas.common import ErrorResponse, MessageResponse, Page
|
||||
from app.schemas.merchant import MerchantCreate, MerchantOut, MerchantUpdate
|
||||
from app.services.crud import apply_updates, get_or_404
|
||||
from app.services.merchants import normalize_name, search_statement
|
||||
|
||||
router = APIRouter(prefix="/merchants", tags=["merchants"])
|
||||
|
||||
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=Page[MerchantOut],
|
||||
summary="Firmen suchen",
|
||||
description="Volltextsuche über Name, normalisierten Namen und Domain.",
|
||||
)
|
||||
async def list_merchants(
|
||||
session: DbSession,
|
||||
q: str | None = Query(default=None, description="Suchbegriff."),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> Page[MerchantOut]:
|
||||
stmt = search_statement(q)
|
||||
total = (
|
||||
await session.execute(select(func.count()).select_from(stmt.order_by(None).subquery()))
|
||||
).scalar_one()
|
||||
items = (await session.execute(stmt.limit(limit).offset(offset))).scalars().all()
|
||||
return Page[MerchantOut](
|
||||
items=[MerchantOut.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=MerchantOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Firma anlegen",
|
||||
description="Antwortet sofort. Der Logo-Status steht zunächst auf `pending`.",
|
||||
)
|
||||
async def create_merchant(payload: MerchantCreate, session: DbSession) -> Merchant:
|
||||
merchant = Merchant(
|
||||
name=payload.name,
|
||||
normalized_name=normalize_name(payload.name),
|
||||
domain=payload.domain,
|
||||
aliases=payload.aliases,
|
||||
brand_color=payload.brand_color,
|
||||
brand_color_dark=payload.brand_color_dark,
|
||||
logo_status=LogoStatus.PENDING,
|
||||
)
|
||||
session.add(merchant)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise ConflictError(f"Die Firma '{payload.name}' ist bereits angelegt.") from exc
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma lesen"
|
||||
)
|
||||
async def read_merchant(merchant_id: int, session: DbSession) -> Merchant:
|
||||
return await get_or_404(session, Merchant, merchant_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma ändern"
|
||||
)
|
||||
async def update_merchant(
|
||||
merchant_id: int, payload: MerchantUpdate, session: DbSession
|
||||
) -> Merchant:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
apply_updates(merchant, payload)
|
||||
if payload.name is not None:
|
||||
merchant.normalized_name = normalize_name(payload.name)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise ConflictError("Eine Firma mit diesem Namen ist bereits angelegt.") from exc
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{merchant_id}",
|
||||
response_model=MessageResponse,
|
||||
responses=NOT_FOUND,
|
||||
summary="Firma löschen",
|
||||
description="Verweise aus Buchungen und Posten werden dabei auf 'keine Firma' gesetzt.",
|
||||
)
|
||||
async def delete_merchant(merchant_id: int, session: DbSession) -> MessageResponse:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
|
||||
# ON DELETE SET NULL greift erst in der Datenbank; die geladenen Objekte
|
||||
# müssen daher explizit nachgezogen werden.
|
||||
for model in (Recurrence, Transaction):
|
||||
stmt = select(model).where(model.merchant_id == merchant_id)
|
||||
for row in (await session.execute(stmt)).scalars():
|
||||
row.merchant_id = None
|
||||
|
||||
await session.delete(merchant)
|
||||
await session.commit()
|
||||
return MessageResponse(detail="Firma gelöscht.")
|
||||
Reference in New Issue
Block a user