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
@@ -990,6 +990,31 @@ class Subscription(Base):
|
|||||||
return round(self.amount_cents / n)
|
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):
|
class BankApp(Base):
|
||||||
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
|
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,19 @@ addieren.
|
|||||||
|
|
||||||
from datetime import date
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
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 (
|
from ..schemas import (
|
||||||
SubscriptionIn,
|
SubscriptionIn,
|
||||||
SubscriptionListOut,
|
SubscriptionListOut,
|
||||||
@@ -25,7 +32,7 @@ from ..schemas import (
|
|||||||
SubscriptionSummary,
|
SubscriptionSummary,
|
||||||
SubscriptionUpdate,
|
SubscriptionUpdate,
|
||||||
)
|
)
|
||||||
from ..services import currency
|
from ..services import currency, logo as logo_service
|
||||||
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
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:
|
def to_out(db: Session, s: Subscription, base: str, heute: date) -> SubscriptionOut:
|
||||||
|
stand = _logo_stand(db, s.id)
|
||||||
monatlich = s.monatlich_cents
|
monatlich = s.monatlich_cents
|
||||||
umgerechnet = (
|
umgerechnet = (
|
||||||
monatlich if s.currency == base
|
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_cents=monatlich,
|
||||||
monatlich_base_cents=umgerechnet,
|
monatlich_base_cents=umgerechnet,
|
||||||
tage_bis_faellig=(s.next_due - heute).days if s.next_due else None,
|
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)):
|
user: User = Depends(get_current_user)):
|
||||||
db.delete(get_owned(db, Subscription, sub_id, user))
|
db.delete(get_owned(db, Subscription, sub_id, user))
|
||||||
db.commit()
|
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())
|
||||||
|
|||||||
@@ -308,6 +308,12 @@ class SubscriptionOut(BaseModel):
|
|||||||
monatlich_base_cents: int | None = None
|
monatlich_base_cents: int | None = None
|
||||||
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
|
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
|
||||||
tage_bis_faellig: int | None = None
|
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):
|
class SubscriptionSummary(BaseModel):
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ Zwei Reichweiten:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date as date_t, datetime, time as time_t
|
from datetime import date as date_t, datetime, time as time_t
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Callable
|
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 Enum as SAEnum
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -49,6 +50,7 @@ from ..models import (
|
|||||||
ImportTemplate,
|
ImportTemplate,
|
||||||
InboxItem,
|
InboxItem,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
SubscriptionLogo,
|
||||||
LinkIgnored,
|
LinkIgnored,
|
||||||
PlannedIncomeLine,
|
PlannedIncomeLine,
|
||||||
RecoveryCode,
|
RecoveryCode,
|
||||||
@@ -138,6 +140,11 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
"savings_account_id": "accounts",
|
"savings_account_id": "accounts",
|
||||||
"category_id": "categories",
|
"category_id": "categories",
|
||||||
}, label="Abos & Vertraege"),
|
}, 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",
|
TableSpec("transfer_links", TransferLink, "owner",
|
||||||
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
|
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
|
||||||
label="Übertrags-Verknüpfungen"),
|
label="Übertrags-Verknüpfungen"),
|
||||||
@@ -192,6 +199,11 @@ _SKIP_COLUMNS = {"id", "owner_id", "user_id"}
|
|||||||
def _dump_value(col, value):
|
def _dump_value(col, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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):
|
if isinstance(col.type, SAEnum):
|
||||||
return value.value if hasattr(value, "value") else str(value)
|
return value.value if hasattr(value, "value") else str(value)
|
||||||
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
if isinstance(col.type, DateTime) or isinstance(value, datetime):
|
||||||
@@ -210,6 +222,11 @@ def _dump_value(col, value):
|
|||||||
def _load_value(col, value):
|
def _load_value(col, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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:
|
try:
|
||||||
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
|
||||||
return col.type.enum_class(value)
|
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(
|
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
|
||||||
BudgetPeriod.owner_id == user.id
|
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
|
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
|
# die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu
|
||||||
# erkennen. Erst danach der Aehnlichkeits-Schluessel.
|
# erkennen. Erst danach der Aehnlichkeits-Schluessel.
|
||||||
"subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()),
|
"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: (
|
"transactions": lambda r: (
|
||||||
("uid", r["source_uid"]) if r.get("source_uid")
|
("uid", r["source_uid"]) if r.get("source_uid")
|
||||||
else ("hash", r["import_hash"]) if r.get("import_hash")
|
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]:
|
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int | None]:
|
||||||
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID."""
|
"""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)
|
schluessel = _MERGE_KEYS.get(spec.key)
|
||||||
if schluessel is None:
|
if schluessel is None:
|
||||||
return {}
|
return {}
|
||||||
cols = _columns(spec)
|
cols = _columns(spec)
|
||||||
treffer: dict[tuple, int] = {}
|
treffer: dict[tuple, int | None] = {}
|
||||||
for obj in _query(db, spec, user).all():
|
for obj in _query(db, spec, user).all():
|
||||||
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
|
||||||
k = schluessel(roh)
|
k = schluessel(roh)
|
||||||
if k is not None:
|
if k is not None:
|
||||||
treffer.setdefault(k, obj.id)
|
treffer.setdefault(k, obj.id if spec.has_id else None)
|
||||||
return treffer
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +499,7 @@ def _insert_rows(
|
|||||||
if merge and schluessel is not None:
|
if merge and schluessel is not None:
|
||||||
k = schluessel(aufgeloest)
|
k = schluessel(aufgeloest)
|
||||||
if k is not None:
|
if k is not None:
|
||||||
vorhanden[k] = obj.id
|
vorhanden[k] = obj.id if spec.has_id else None
|
||||||
neu += 1
|
neu += 1
|
||||||
return neu
|
return neu
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Ein Markenlogo je Vertrag.
|
||||||
|
|
||||||
|
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt man
|
||||||
|
am roten N, nicht am Wort. Das Symbol aus der Auswahl bleibt als Rueckfall
|
||||||
|
fuer alles, wozu es kein Logo gibt.
|
||||||
|
|
||||||
|
Eigene Tabelle: Ein Blob an ``subscriptions`` zoege bei jeder Listenabfrage
|
||||||
|
saemtliche Bilder mit heraus.
|
||||||
|
|
||||||
|
Revision ID: 0031
|
||||||
|
Revises: 0030
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0031"
|
||||||
|
down_revision = "0030"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"subscription_logos",
|
||||||
|
sa.Column("subscription_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("subscriptions.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True),
|
||||||
|
sa.Column("content", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("subscription_logos")
|
||||||
@@ -5,6 +5,7 @@ Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber
|
|||||||
Waehrungen und ueber verschiedene Intervalle hinweg.
|
Waehrungen und ueber verschiedene Intervalle hinweg.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -178,3 +179,122 @@ def test_angebotene_waehrungen_sind_eine_einstellung(admin):
|
|||||||
assert abo["currency"] == "USD"
|
assert abo["currency"] == "USD"
|
||||||
finally:
|
finally:
|
||||||
admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF,USD"})
|
admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF,USD"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Markenlogo --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bild(groesse=(600, 400), farbe=(200, 30, 30, 255)) -> bytes:
|
||||||
|
"""Ein echtes PNG, absichtlich zu gross – so kommt es aus dem Alltag."""
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
puffer = _io.BytesIO()
|
||||||
|
Image.new("RGBA", groesse, farbe).save(puffer, format="PNG")
|
||||||
|
return puffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _hochladen(admin, sub_id, daten=None, name="logo.png", typ="image/png"):
|
||||||
|
return admin.put(f"/api/subscriptions/{sub_id}/logo",
|
||||||
|
files={"file": (name, daten if daten is not None else _bild(), typ)})
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_wird_klein_gerechnet_und_ausgeliefert(admin):
|
||||||
|
"""Hochgeladen wird, was das Telefon hergibt – gespeichert ein Bildchen."""
|
||||||
|
from app.services import logo
|
||||||
|
|
||||||
|
abo = _abo(admin)
|
||||||
|
assert abo["has_logo"] is False
|
||||||
|
|
||||||
|
r = _hochladen(admin, abo["id"])
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_logo"] is True
|
||||||
|
assert r.json()["logo_version"] is not None
|
||||||
|
|
||||||
|
bild = admin.get(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert bild.status_code == 200
|
||||||
|
assert bild.headers["content-type"] == "image/png"
|
||||||
|
assert len(bild.content) < 20_000, "Logo nicht verkleinert"
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import io as _io
|
||||||
|
gespeichert = Image.open(_io.BytesIO(bild.content))
|
||||||
|
assert max(gespeichert.size) <= logo.MAX_KANTE
|
||||||
|
# Seitenverhaeltnis bleibt: ein verzerrtes Logo sieht falscher aus als ein
|
||||||
|
# kleines. 600x400 ist 3:2.
|
||||||
|
assert gespeichert.size == (128, 85), gespeichert.size
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_logo_gibt_es_kein_bild(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_laesst_sich_austauschen_und_entfernen(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"], _bild((300, 300), (10, 200, 10, 255)))
|
||||||
|
erst = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
|
||||||
|
_hochladen(admin, abo["id"], _bild((300, 300), (10, 10, 200, 255)))
|
||||||
|
zweit = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
assert zweit != erst, "das alte Logo blieb stehen"
|
||||||
|
|
||||||
|
r = admin.delete(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_logo"] is False
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_was_kein_bild_ist_wird_verstaendlich_abgewiesen(admin):
|
||||||
|
abo = _abo(admin)
|
||||||
|
r = _hochladen(admin, abo["id"], b"kein bild, nur text", "notiz.txt", "text/plain")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Bild" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_eines_fremden_vertrags_bleibt_verschlossen(admin):
|
||||||
|
"""Sonst laege es hinter einer geratenen Nummer offen."""
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"])
|
||||||
|
username, password = new_user(admin)
|
||||||
|
anderer, _ = login_ready(username, password)
|
||||||
|
assert anderer.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_geloeschter_vertrag_nimmt_sein_logo_mit(admin):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import SubscriptionLogo
|
||||||
|
|
||||||
|
abo = _abo(admin)
|
||||||
|
_hochladen(admin, abo["id"])
|
||||||
|
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
|
||||||
|
with SessionLocal() as s:
|
||||||
|
assert s.get(SubscriptionLogo, abo["id"]) is None, "Logo blieb verwaist zurueck"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_uebersteht_sichern_und_einspielen(admin):
|
||||||
|
"""Ein Bild ist binaer – ohne Base64 scheitert schon das Schreiben der
|
||||||
|
Sicherung, und zwar erst, wenn der Nutzer sie zu haben glaubt."""
|
||||||
|
abo = _abo(admin, name="Logo-Backup " + secrets.token_hex(3))
|
||||||
|
_hochladen(admin, abo["id"], _bild((200, 200), (7, 77, 177, 255)))
|
||||||
|
vorher = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
|
||||||
|
|
||||||
|
sicherung = admin.get("/api/export/backup.json")
|
||||||
|
assert sicherung.status_code == 200, sicherung.text
|
||||||
|
roh = sicherung.content
|
||||||
|
daten = json.loads(roh)
|
||||||
|
haufen = daten.get("data", daten)
|
||||||
|
logos = haufen.get("subscription_logos")
|
||||||
|
assert logos, f"Logos fehlen in der Sicherung: {sorted(haufen)[:25]}"
|
||||||
|
|
||||||
|
admin.delete(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
|
||||||
|
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", roh, "application/json")},
|
||||||
|
data={"mode": "merge"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
nachher = admin.get(f"/api/subscriptions/{abo['id']}/logo")
|
||||||
|
assert nachher.status_code == 200, "Logo kam nicht zurueck"
|
||||||
|
assert nachher.content == vorher, "Logo kam veraendert zurueck"
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ export const api = {
|
|||||||
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
||||||
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
||||||
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
||||||
|
// Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body.
|
||||||
|
uploadSubscriptionLogo: (id, datei) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", datei);
|
||||||
|
return request(`/subscriptions/${id}/logo`, { method: "PUT", formData: fd });
|
||||||
|
},
|
||||||
|
deleteSubscriptionLogo: (id) => request(`/subscriptions/${id}/logo`, { method: "DELETE" }),
|
||||||
|
subscriptionLogoUrl: (id, version) =>
|
||||||
|
`/api/subscriptions/${id}/logo${version ? `?v=${version}` : ""}`,
|
||||||
|
|
||||||
listCategories: () => request("/categories"),
|
listCategories: () => request("/categories"),
|
||||||
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
||||||
|
|||||||
@@ -1749,3 +1749,25 @@ tr.row-dup {
|
|||||||
.waehrung-kachel.an { border-color: var(--accent, var(--border-strong)); }
|
.waehrung-kachel.an { border-color: var(--accent, var(--border-strong)); }
|
||||||
.waehrung-kachel input { width: auto; margin: 0; }
|
.waehrung-kachel input { width: auto; margin: 0; }
|
||||||
.waehrung-kachel:has(input:disabled) { opacity: 0.6; cursor: default; }
|
.waehrung-kachel:has(input:disabled) { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
/* Logo statt Symbol. Gleiche Kantenlänge wie der IconChip daneben, damit die
|
||||||
|
Liste nicht wackelt, je nachdem ob ein Vertrag ein Logo hat. */
|
||||||
|
.abo-logo {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.abo-logo.gross { width: 48px; height: 48px; border-radius: 8px; }
|
||||||
|
|
||||||
|
.abo-logo-feld {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
.abo-logo-knoepfe { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
/* Das Dateifeld steckt in einem <label>, das wie ein Knopf aussieht. */
|
||||||
|
.abo-logo-knoepfe label.btn { cursor: pointer; margin: 0; }
|
||||||
|
.abo-logo-knoepfe label.btn.deaktiviert { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -366,7 +366,12 @@
|
|||||||
"patternHint": "Woran die Abbuchung im Auszug zu erkennen ist. Auf dem Auszug steht selten der Name, unter dem du den Vertrag kennst. Wird noch nicht ausgewertet – kommt mit der automatischen Zuordnung.",
|
"patternHint": "Woran die Abbuchung im Auszug zu erkennen ist. Auf dem Auszug steht selten der Name, unter dem du den Vertrag kennst. Wird noch nicht ausgewertet – kommt mit der automatischen Zuordnung.",
|
||||||
"cancelledFlag": "Gekündigt",
|
"cancelledFlag": "Gekündigt",
|
||||||
"cancelledFlagHint": "Zählt dann nicht mehr in der Monatssumme, bleibt aber in der Liste stehen.",
|
"cancelledFlagHint": "Zählt dann nicht mehr in der Monatssumme, bleibt aber in der Liste stehen.",
|
||||||
"notes": "Notiz"
|
"notes": "Notiz",
|
||||||
|
"logo": "Logo",
|
||||||
|
"logoUpload": "Bild wählen",
|
||||||
|
"logoRemove": "Entfernen",
|
||||||
|
"logoHint": "PNG, JPEG, WEBP oder GIF. Wird auf Kachelgrösse gerechnet – lad ruhig das grosse Bild hoch. Solange kein Logo da ist, gilt das Symbol unten.",
|
||||||
|
"logoAfterSave": "Ein Logo lässt sich anhängen, sobald der Vertrag gespeichert ist. Bis dahin gilt das Symbol unten."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Import",
|
"title": "Import",
|
||||||
|
|||||||
@@ -366,7 +366,12 @@
|
|||||||
"patternHint": "How the charge appears on the statement — rarely the name you know it by. Not used yet; it arrives with automatic matching.",
|
"patternHint": "How the charge appears on the statement — rarely the name you know it by. Not used yet; it arrives with automatic matching.",
|
||||||
"cancelledFlag": "Cancelled",
|
"cancelledFlag": "Cancelled",
|
||||||
"cancelledFlagHint": "Stops counting towards the monthly total but stays in the list.",
|
"cancelledFlagHint": "Stops counting towards the monthly total but stays in the list.",
|
||||||
"notes": "Note"
|
"notes": "Note",
|
||||||
|
"logo": "Logo",
|
||||||
|
"logoUpload": "Choose image",
|
||||||
|
"logoRemove": "Remove",
|
||||||
|
"logoHint": "PNG, JPEG, WEBP or GIF. It is scaled down for you – upload the large one. While there is no logo, the icon below is used.",
|
||||||
|
"logoAfterSave": "A logo can be attached once the contract is saved. Until then the icon below is used."
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Import",
|
"title": "Import",
|
||||||
|
|||||||
+62
-1
@@ -136,6 +136,31 @@ export default function Abos() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function logoWaehlen(datei) {
|
||||||
|
if (!datei || !editing) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const aktualisiert = await api.uploadSubscriptionLogo(editing.id, datei);
|
||||||
|
// Auch den offenen Dialog nachziehen, sonst zeigt er weiter das Symbol.
|
||||||
|
setEditing(aktualisiert);
|
||||||
|
laden();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoEntfernen() {
|
||||||
|
if (!editing) return;
|
||||||
|
try {
|
||||||
|
setEditing(await api.deleteSubscriptionLogo(editing.id));
|
||||||
|
laden();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loeschen(s) {
|
async function loeschen(s) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("abos.delete"),
|
title: t("abos.delete"),
|
||||||
@@ -176,7 +201,14 @@ export default function Abos() {
|
|||||||
const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : "";
|
const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : "";
|
||||||
return (
|
return (
|
||||||
<div className={"abo-zeile" + beendet}>
|
<div className={"abo-zeile" + beendet}>
|
||||||
<IconChip name={s.icon} color={s.color} size={30} />
|
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N,
|
||||||
|
nicht am Wort – und erst recht nicht an einem Zahnrad. */}
|
||||||
|
{s.has_logo ? (
|
||||||
|
<img className="abo-logo" alt=""
|
||||||
|
src={api.subscriptionLogoUrl(s.id, s.logo_version)} />
|
||||||
|
) : (
|
||||||
|
<IconChip name={s.icon} color={s.color} size={30} />
|
||||||
|
)}
|
||||||
<div className="abo-mitte">
|
<div className="abo-mitte">
|
||||||
<div className="abo-kopf">
|
<div className="abo-kopf">
|
||||||
<strong className="ellipsis">{s.name}</strong>
|
<strong className="ellipsis">{s.name}</strong>
|
||||||
@@ -428,6 +460,35 @@ export default function Abos() {
|
|||||||
</label>
|
</label>
|
||||||
<p className="muted small">{t("abos.patternHint")}</p>
|
<p className="muted small">{t("abos.patternHint")}</p>
|
||||||
|
|
||||||
|
<label style={{ marginBottom: 6 }}>
|
||||||
|
<span>{t("abos.logo")}</span>
|
||||||
|
</label>
|
||||||
|
<div className="abo-logo-feld">
|
||||||
|
{editing?.has_logo ? (
|
||||||
|
<img className="abo-logo gross" alt=""
|
||||||
|
src={api.subscriptionLogoUrl(editing.id, editing.logo_version)} />
|
||||||
|
) : (
|
||||||
|
<IconChip name={form.icon} color={form.color} size={48} />
|
||||||
|
)}
|
||||||
|
<div className="abo-logo-knoepfe">
|
||||||
|
{/* Ohne gespeicherten Vertrag gibt es noch keine Nummer, an die
|
||||||
|
das Bild gehoert. Erst speichern, dann Logo. */}
|
||||||
|
<label className={"btn sm" + (editing ? "" : " deaktiviert")}>
|
||||||
|
<Icon name="upload" size={14} /> {t("abos.logoUpload")}
|
||||||
|
<input type="file" accept="image/*" hidden disabled={!editing}
|
||||||
|
onChange={(e) => logoWaehlen(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
{editing?.has_logo && (
|
||||||
|
<button type="button" className="btn sm ghost" onClick={logoEntfernen}>
|
||||||
|
{t("abos.logoRemove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
{editing ? t("abos.logoHint") : t("abos.logoAfterSave")}
|
||||||
|
</p>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>{t("accounts.icon")}</span>
|
<span>{t("accounts.icon")}</span>
|
||||||
<IconPicker
|
<IconPicker
|
||||||
|
|||||||
Reference in New Issue
Block a user