- 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
119 lines
4.1 KiB
Python
119 lines
4.1 KiB
Python
"""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.")
|