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:
@@ -12,6 +12,7 @@ from app.api.routes import (
|
||||
auth,
|
||||
budgets,
|
||||
categories,
|
||||
logos,
|
||||
me,
|
||||
merchants,
|
||||
occurrences,
|
||||
@@ -34,6 +35,7 @@ protected = APIRouter(dependencies=[Depends(get_active_user)])
|
||||
protected.include_router(accounts.router)
|
||||
protected.include_router(categories.router)
|
||||
protected.include_router(merchants.router)
|
||||
protected.include_router(logos.router)
|
||||
protected.include_router(recurrences.router)
|
||||
protected.include_router(occurrences.router)
|
||||
protected.include_router(transactions.router)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user