feat(logos): Provider-Kette, lokaler Cache und Markenfarben

- 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
This commit is contained in:
moneyfy
2026-09-09 13:54:21 +02:00
co-authored by Claude Opus 5
parent b586d27b77
commit 8edb69fe6a
16 changed files with 2039 additions and 7 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Auslieferung der zwischengespeicherten Logos.
Die Dateien kommen ausschließlich von der lokalen Platte; beim Seitenaufruf
entsteht kein Zugriff auf fremde Dienste. Der Dateiname ist der Inhaltshash,
daher darf unbegrenzt zwischengespeichert werden.
"""
from fastapi import APIRouter, Request, Response, status
from fastapi.responses import FileResponse
from app.api.deps import DbSession
from app.core.config import settings
from app.core.errors import NotFoundError
from app.models import LogoAsset
from app.schemas.common import ErrorResponse
from app.services.crud import get_or_404
from app.services.logos import cache_headers
router = APIRouter(prefix="/logos", tags=["logos"])
@router.get(
"/{asset_id}",
responses={
status.HTTP_200_OK: {
"content": {"image/svg+xml": {}, "image/png": {}, "image/jpeg": {}},
"description": "Die Logodatei aus dem lokalen Cache.",
},
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
},
summary="Logo ausliefern",
description="Liefert die Datei mit `Cache-Control: public, max-age=31536000, immutable`.",
)
async def read_logo(asset_id: int, request: Request, session: DbSession) -> Response:
asset = await get_or_404(session, LogoAsset, asset_id)
pfad = settings.logo_storage_dir / asset.file_path
if not pfad.exists():
raise NotFoundError("Die Logodatei fehlt im Cache.", code="logo_file_missing")
headers = cache_headers(asset)
# Unveränderliche Inhalte: ein passendes ETag beantwortet die Anfrage sofort.
if request.headers.get("if-none-match") == headers["ETag"]:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
return FileResponse(pfad, media_type=asset.mime, headers=headers)
+150 -4
View File
@@ -1,16 +1,32 @@
"""Firmen und Zahlungsempfänger."""
from fastapi import APIRouter, Query, status
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 MerchantCreate, MerchantOut, MerchantUpdate
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"])
@@ -48,9 +64,12 @@ async def list_merchants(
response_model=MerchantOut,
status_code=status.HTTP_201_CREATED,
summary="Firma anlegen",
description="Antwortet sofort. Der Logo-Status steht zunächst auf `pending`.",
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, session: DbSession) -> Merchant:
async def create_merchant(
payload: MerchantCreate, background: BackgroundTasks, session: DbSession
) -> Merchant:
merchant = Merchant(
name=payload.name,
normalized_name=normalize_name(payload.name),
@@ -67,6 +86,10 @@ async def create_merchant(payload: MerchantCreate, session: DbSession) -> Mercha
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
@@ -116,3 +139,126 @@ async def delete_merchant(merchant_id: int, session: DbSession) -> MessageRespon
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