feat: Ein Markenlogo je Vertrag

Am Logo findet man den Vertrag schneller als am Namen: Netflix erkennt man am
roten N, nicht am Wort – und erst recht nicht an einem Zahnrad aus der
Symbolauswahl. Die bleibt als Rueckfall fuer alles, wozu es kein Logo gibt.

Hochgeladen wird, was das Telefon hergibt; gespeichert ein PNG von hoechstens
128 Pixeln Kantenlaenge. Aus drei Megabyte werden ein paar Kilobyte, und das
Seitenverhaeltnis bleibt – ein verzerrtes Logo sieht falscher aus als ein
kleines.

Immer PNG, egal was hereinkam. Das erspart der Anzeige jede
Fallunterscheidung und schliesst SVG aus: Eine SVG-Datei kann Skript
enthalten, und ein Bild, das der Nutzer hochlaedt und der Browser ausfuehrt,
ist genau die Art Falle, die man sich nicht in ein Finanzprogramm legt.

Eigene Tabelle, nicht eine Spalte an den Vertraegen: Sonst zoege jede
Listenabfrage saemtliche Bilder mit heraus, obwohl die Liste sie einzeln
nachlaedt.

ZWEI FEHLER IM SICHERN, die dabei aufgefallen sind:

Binaeres liess sich gar nicht sichern – die Ausgabe scheiterte an den Bytes,
und zwar erst beim Schreiben, also nachdem der Nutzer glaubt, ein Backup zu
haben. Jetzt Base64.

Und "Ergaenzen" legte Zeilen von Tabellen mit natuerlichem Schluessel ein
zweites Mal an, statt sie wiederzuerkennen. Beim Logo kippte damit die ganze
Wiederherstellung. Betraf bisher keine Tabelle, weil noch keine solche einen
Vergleichsschluessel hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 19:35:37 +02:00
co-authored by Claude Opus 5
parent 0a790fd45a
commit dd2c33725f
12 changed files with 484 additions and 12 deletions
+25
View File
@@ -990,6 +990,31 @@ class Subscription(Base):
return round(self.amount_cents / n)
class SubscriptionLogo(Base):
"""Das Markenlogo eines Vertrags.
Eigene Tabelle und nicht eine Spalte an ``subscriptions``: Sonst zoege
jede Listenabfrage saemtliche Bilder mit aus der Datenbank, obwohl die
Liste sie einzeln nachlaedt. Ein Blob in einer Zeile, die man oft und
vollstaendig liest, ist teuer.
Gespeichert wird immer PNG, klein gerechnet – hochgeladen wird, was das
Telefon hergibt, und das sind schnell drei Megabyte fuer ein Bildchen von
dreissig Pixeln Kantenlaenge.
"""
__tablename__ = "subscription_logos"
subscription_id: Mapped[int] = mapped_column(
ForeignKey("subscriptions.id", ondelete="CASCADE"), primary_key=True
)
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class BankApp(Base):
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
+92 -3
View File
@@ -12,12 +12,19 @@ addieren.
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, Category, CategoryKind, Subscription, User
from ..models import (
Account,
Category,
CategoryKind,
Subscription,
SubscriptionLogo,
User,
)
from ..schemas import (
SubscriptionIn,
SubscriptionListOut,
@@ -25,7 +32,7 @@ from ..schemas import (
SubscriptionSummary,
SubscriptionUpdate,
)
from ..services import currency
from ..services import currency, logo as logo_service
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
@@ -54,7 +61,26 @@ def _pruefe_verweise(db: Session, user: User, daten: dict) -> None:
)
def _logo_stand(db: Session, sub_id: int) -> int | None:
"""Wann das Logo zuletzt geaendert wurde – oder None, wenn es keins gibt.
Bewusst NICHT das Bild: Die Liste laedt es einzeln nach, sonst haenge sie
voller Base64. Der Zeitstempel wandert in die Bild-Adresse und ueberholt
damit den Zwischenspeicher des Browsers; ohne ihn zeigte er nach dem
Austauschen weiter das alte Logo.
"""
row = (
db.query(SubscriptionLogo.updated_at)
.filter(SubscriptionLogo.subscription_id == sub_id)
.first()
)
if row is None:
return None
return int(row[0].timestamp()) if row[0] else 0
def to_out(db: Session, s: Subscription, base: str, heute: date) -> SubscriptionOut:
stand = _logo_stand(db, s.id)
monatlich = s.monatlich_cents
umgerechnet = (
monatlich if s.currency == base
@@ -69,6 +95,8 @@ def to_out(db: Session, s: Subscription, base: str, heute: date) -> Subscription
monatlich_cents=monatlich,
monatlich_base_cents=umgerechnet,
tage_bis_faellig=(s.next_due - heute).days if s.next_due else None,
has_logo=stand is not None,
logo_version=stand,
)
@@ -160,3 +188,64 @@ def delete_subscription(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
db.delete(get_owned(db, Subscription, sub_id, user))
db.commit()
# --- Markenlogo -------------------------------------------------------------
@router.put("/{sub_id}/logo", response_model=SubscriptionOut)
async def upload_logo(sub_id: int, file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Ein Logo hochladen. Ersetzt ein vorhandenes.
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt
man am roten N, nicht am Wort.
"""
s = get_owned(db, Subscription, sub_id, user)
try:
klein = logo_service.mach_logo(await file.read())
except logo_service.LogoFehler as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
vorhanden = db.get(SubscriptionLogo, s.id)
if vorhanden is None:
db.add(SubscriptionLogo(subscription_id=s.id, content=klein,
size_bytes=len(klein)))
else:
vorhanden.content = klein
vorhanden.size_bytes = len(klein)
db.commit()
return to_out(db, s, _base(db, user), date.today())
@router.get("/{sub_id}/logo")
def get_logo(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
# Ueber get_owned, nicht ueber die Logo-Tabelle direkt: Sonst laege das
# Bild eines fremden Vertrags hinter einer geratenen Nummer offen.
s = get_owned(db, Subscription, sub_id, user)
row = db.get(SubscriptionLogo, s.id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Logo")
return Response(
content=row.content,
media_type=logo_service.MIME,
headers={
# Kurz, aber nicht null: Die Liste fragt es je Zeile ab. Beim
# Austauschen haengt die Fassung in der Adresse (?v=...), das
# ueberholt den Zwischenspeicher.
"Cache-Control": "private, max-age=300",
},
)
@router.delete("/{sub_id}/logo", response_model=SubscriptionOut)
def delete_logo(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
s = get_owned(db, Subscription, sub_id, user)
row = db.get(SubscriptionLogo, s.id)
if row is not None:
db.delete(row)
db.commit()
return to_out(db, s, _base(db, user), date.today())
+6
View File
@@ -308,6 +308,12 @@ class SubscriptionOut(BaseModel):
monatlich_base_cents: int | None = None
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
tage_bis_faellig: int | None = None
# Ob ein Markenlogo hinterlegt ist. Nur die Angabe, nicht das Bild – das
# holt die Anzeige einzeln, sonst hinge die Liste voller Base64.
has_logo: bool = False
# Ändert sich beim Austauschen und überholt so den Zwischenspeicher des
# Browsers – ohne das zeigte er nach dem Wechsel weiter das alte Bild.
logo_version: int | None = None
class SubscriptionSummary(BaseModel):
+37 -6
View File
@@ -20,13 +20,14 @@ Zwei Reichweiten:
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from datetime import date as date_t, datetime, time as time_t
from decimal import Decimal
from typing import Callable
from sqlalchemy import Boolean, Date, DateTime, Numeric, Time
from sqlalchemy import Boolean, Date, DateTime, LargeBinary, Numeric, Time
from sqlalchemy import Enum as SAEnum
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import Session
@@ -49,6 +50,7 @@ from ..models import (
ImportTemplate,
InboxItem,
Subscription,
SubscriptionLogo,
LinkIgnored,
PlannedIncomeLine,
RecoveryCode,
@@ -138,6 +140,11 @@ USER_TABLES: list[TableSpec] = [
"savings_account_id": "accounts",
"category_id": "categories",
}, label="Abos & Vertraege"),
# Das Logo gehoert zum Vertrag. Klein gerechnet ist es ein paar Kilobyte –
# muehsam nachzutragen waere es trotzdem.
TableSpec("subscription_logos", SubscriptionLogo, "subscription",
{"subscription_id": "subscriptions"}, has_id=False,
label="Vertrags-Logos"),
TableSpec("transfer_links", TransferLink, "owner",
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
label="Übertrags-Verknüpfungen"),
@@ -192,6 +199,11 @@ _SKIP_COLUMNS = {"id", "owner_id", "user_id"}
def _dump_value(col, value):
if value is None:
return None
# Binaeres als Base64. Ohne das scheitert das Sichern an der JSON-Ausgabe –
# und zwar erst beim Schreiben, also nachdem der Nutzer schon glaubt, ein
# Backup zu haben.
if isinstance(col.type, LargeBinary) or isinstance(value, (bytes, bytearray)):
return base64.b64encode(bytes(value)).decode("ascii")
if isinstance(col.type, SAEnum):
return value.value if hasattr(value, "value") else str(value)
if isinstance(col.type, DateTime) or isinstance(value, datetime):
@@ -210,6 +222,11 @@ def _dump_value(col, value):
def _load_value(col, value):
if value is None:
return None
if isinstance(col.type, LargeBinary):
try:
return base64.b64decode(value)
except (ValueError, TypeError) as exc:
raise BackupError(f"Unlesbares Bild für '{col.key}'") from exc
try:
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
return col.type.enum_class(value)
@@ -250,6 +267,10 @@ def _query(db: Session, spec: TableSpec, user: User | None):
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
BudgetPeriod.owner_id == user.id
)
elif spec.scope == "subscription":
q = q.join(Subscription, spec.model.subscription_id == Subscription.id).filter(
Subscription.owner_id == user.id
)
return q
@@ -379,6 +400,10 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
# die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu
# erkennen. Erst danach der Aehnlichkeits-Schluessel.
"subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()),
# Ohne diesen Eintrag legt "Ergaenzen" das Logo eines schon vorhandenen
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
"transactions": lambda r: (
("uid", r["source_uid"]) if r.get("source_uid")
else ("hash", r["import_hash"]) if r.get("import_hash")
@@ -401,18 +426,24 @@ _ALL_OR_NOTHING = {
}
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int]:
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID."""
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int | None]:
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID.
Bei Tabellen mit natürlichem Schlüssel (``has_id=False``) gibt es keine
``id`` – dort zählt allein, DASS die Zeile schon da ist. Der Wert bleibt
dann leer; gebraucht wird er nur für die Verweis-Übersetzung, und die
betrifft solche Tabellen nicht.
"""
schluessel = _MERGE_KEYS.get(spec.key)
if schluessel is None:
return {}
cols = _columns(spec)
treffer: dict[tuple, int] = {}
treffer: dict[tuple, int | None] = {}
for obj in _query(db, spec, user).all():
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
k = schluessel(roh)
if k is not None:
treffer.setdefault(k, obj.id)
treffer.setdefault(k, obj.id if spec.has_id else None)
return treffer
@@ -468,7 +499,7 @@ def _insert_rows(
if merge and schluessel is not None:
k = schluessel(aufgeloest)
if k is not None:
vorhanden[k] = obj.id
vorhanden[k] = obj.id if spec.has_id else None
neu += 1
return neu
+63
View File
@@ -0,0 +1,63 @@
"""Ein hochgeladenes Bild zu einem brauchbaren Logo machen.
Was aus einem Formular kommt, ist selten das, was man anzeigen will: ein
Screenshot vom Telefon, drei Megabyte, 3000 Pixel breit, für eine Kachel von
dreissig. Hier wird daraus ein kleines PNG.
Immer PNG, egal was hereinkam. Das erspart der Anzeige jede Fallunterscheidung
und schliesst SVG aus – eine SVG-Datei kann Skript enthalten, und ein Bild,
das der Nutzer hochlädt und der Browser ausführt, ist genau die Art Falle, die
man sich nicht in ein Finanzprogramm legt.
"""
from __future__ import annotations
import io
from PIL import Image, UnidentifiedImageError
# Was hochgeladen werden darf. Grosszügig – gerechnet wird ohnehin klein.
MAX_UPLOAD = 4 * 1024 * 1024
# Kantenlänge des gespeicherten Bilds. 128 statt 64: Auf einem Bildschirm mit
# doppelter Pixeldichte ist eine 64er-Kachel sonst sichtbar unscharf.
MAX_KANTE = 128
MIME = "image/png"
class LogoFehler(ValueError):
"""Das Bild lässt sich nicht verwenden – mit einem Grund für den Nutzer."""
def mach_logo(daten: bytes) -> bytes:
"""Aus hochgeladenen Bytes ein kleines PNG machen.
Das Seitenverhältnis bleibt: Ein verzerrtes Logo sieht falscher aus als
ein kleines.
"""
if not daten:
raise LogoFehler("Die Datei ist leer.")
if len(daten) > MAX_UPLOAD:
raise LogoFehler(
f"Das Bild ist zu gross ({len(daten) // 1024} KB, erlaubt sind "
f"{MAX_UPLOAD // 1024 // 1024} MB)."
)
try:
bild = Image.open(io.BytesIO(daten))
bild.load()
except (UnidentifiedImageError, OSError) as e:
raise LogoFehler(
"Das ist kein lesbares Bild. PNG, JPEG, WEBP oder GIF gehen."
) from e
# Durchsichtigkeit erhalten – Logos sind meist freigestellt, und auf
# dunklem Grund fiele ein weisser Kasten sofort auf.
if bild.mode not in ("RGBA", "LA"):
bild = bild.convert("RGBA")
bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS)
ziel = io.BytesIO()
bild.save(ziel, format="PNG", optimize=True)
return ziel.getvalue()