- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
265 lines
8.9 KiB
Python
265 lines
8.9 KiB
Python
"""Firmen und Zahlungsempfänger."""
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, File, Query, UploadFile, status
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from app.api.deps import DbSession
|
||
from app.core.config import settings
|
||
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 (
|
||
LogoCandidateOut,
|
||
LogoSearchOut,
|
||
LogoSelectRequest,
|
||
MerchantCreate,
|
||
MerchantOut,
|
||
MerchantUpdate,
|
||
)
|
||
from app.services.crud import apply_updates, get_or_404
|
||
from app.services.logos import (
|
||
apply_upload,
|
||
resolve_merchant_logo,
|
||
resolve_merchant_logo_task,
|
||
search_candidates,
|
||
select_candidate,
|
||
store_candidate,
|
||
)
|
||
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. Die Logosuche läuft anschließend im Hintergrund; "
|
||
"der Status wechselt dabei von `pending` auf `resolved`.",
|
||
)
|
||
async def create_merchant(
|
||
payload: MerchantCreate, background: BackgroundTasks, 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)
|
||
|
||
if settings.logo_auto_resolve:
|
||
# Läuft erst nach der Antwort – der Aufrufer wartet nicht auf die Suche.
|
||
background.add_task(resolve_merchant_logo_task, merchant.id)
|
||
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.")
|
||
|
||
|
||
# --- Logos ---------------------------------------------------------------------
|
||
|
||
|
||
def _to_candidate_out(asset_id: int, kandidat, mime: str, is_best: bool) -> LogoCandidateOut:
|
||
return LogoCandidateOut(
|
||
candidate_id=asset_id,
|
||
source=kandidat.source,
|
||
title=kandidat.title,
|
||
score=kandidat.score,
|
||
mime=mime,
|
||
width=kandidat.width,
|
||
height=kandidat.height,
|
||
brand_color=kandidat.brand_color,
|
||
is_preselected=is_best,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{merchant_id}/logo/search",
|
||
response_model=LogoSearchOut,
|
||
responses=NOT_FOUND,
|
||
summary="Logos suchen",
|
||
description="Arbeitet die gesamte Provider-Kette ab und liefert bis zu fünf "
|
||
"Kandidaten. Alle werden im lokalen Cache abgelegt und sind sofort über "
|
||
"`/api/logos/{id}` abrufbar. Der beste Treffer ist vorausgewählt. Die Auswahl "
|
||
"der Firma wird dabei noch nicht verändert.",
|
||
)
|
||
async def search_logos(
|
||
merchant_id: int,
|
||
session: DbSession,
|
||
domain: str | None = Query(
|
||
default=None, description="Überschreibt die hinterlegte Domain für diese Suche."
|
||
),
|
||
) -> LogoSearchOut:
|
||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||
|
||
if domain is not None:
|
||
merchant.domain = domain or None
|
||
|
||
kandidaten = await search_candidates(merchant.name, merchant.domain)
|
||
ausgabe: list[LogoCandidateOut] = []
|
||
for index, kandidat in enumerate(kandidaten):
|
||
asset = await store_candidate(session, kandidat)
|
||
ausgabe.append(_to_candidate_out(asset.id, kandidat, asset.mime, index == 0))
|
||
|
||
await session.commit()
|
||
return LogoSearchOut(merchant_id=merchant.id, candidates=ausgabe)
|
||
|
||
|
||
@router.post(
|
||
"/{merchant_id}/logo/select",
|
||
response_model=MerchantOut,
|
||
responses=NOT_FOUND,
|
||
summary="Logo auswählen",
|
||
description="Übernimmt einen Kandidaten aus der Suche. Der Status wechselt auf "
|
||
"`manual`; die automatische Suche überschreibt die Auswahl danach nicht mehr.",
|
||
)
|
||
async def select_logo(merchant_id: int, payload: LogoSelectRequest, session: DbSession) -> Merchant:
|
||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||
await select_candidate(session, merchant, payload.candidate_id)
|
||
await session.commit()
|
||
await session.refresh(merchant)
|
||
return merchant
|
||
|
||
|
||
@router.post(
|
||
"/{merchant_id}/logo/upload",
|
||
response_model=MerchantOut,
|
||
responses=NOT_FOUND,
|
||
summary="Logo hochladen",
|
||
description="Nimmt SVG, PNG oder JPEG bis 1 MB entgegen. Der Status wechselt auf "
|
||
"`manual`, die Markenfarbe wird aus der Datei ermittelt.",
|
||
)
|
||
async def upload_logo(
|
||
merchant_id: int, session: DbSession, file: UploadFile = File(description="Bilddatei.")
|
||
) -> Merchant:
|
||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||
inhalt = await file.read()
|
||
await apply_upload(session, merchant, inhalt, file.content_type)
|
||
await session.commit()
|
||
await session.refresh(merchant)
|
||
return merchant
|
||
|
||
|
||
@router.post(
|
||
"/{merchant_id}/logo/resolve",
|
||
response_model=MerchantOut,
|
||
responses=NOT_FOUND,
|
||
summary="Logosuche erneut anstoßen",
|
||
description="Sucht und übernimmt den besten Treffer sofort. Mit `force=true` wird "
|
||
"auch eine manuelle Auswahl ersetzt.",
|
||
)
|
||
async def resolve_logo(
|
||
merchant_id: int,
|
||
session: DbSession,
|
||
force: bool = Query(default=False, description="Manuelle Auswahl überschreiben."),
|
||
) -> Merchant:
|
||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||
await resolve_merchant_logo(session, merchant, force=force)
|
||
await session.commit()
|
||
await session.refresh(merchant)
|
||
return merchant
|
||
|
||
|
||
@router.delete(
|
||
"/{merchant_id}/logo",
|
||
response_model=MerchantOut,
|
||
responses=NOT_FOUND,
|
||
summary="Logo entfernen",
|
||
description="Löst die Zuordnung und setzt den Status zurück auf `pending`.",
|
||
)
|
||
async def remove_logo(merchant_id: int, session: DbSession) -> Merchant:
|
||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||
merchant.logo_asset_id = None
|
||
merchant.logo_source = None
|
||
merchant.logo_status = LogoStatus.PENDING
|
||
merchant.brand_color = None
|
||
merchant.brand_color_dark = None
|
||
await session.commit()
|
||
await session.refresh(merchant)
|
||
return merchant
|