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:
co-authored by
Claude Opus 5
parent
0a790fd45a
commit
dd2c33725f
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user