diff --git a/backend/app/models.py b/backend/app/models.py index 23f58cd..eed4190 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/routers/subscriptions.py b/backend/app/routers/subscriptions.py index 6a9cf38..cf57a03 100644 --- a/backend/app/routers/subscriptions.py +++ b/backend/app/routers/subscriptions.py @@ -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()) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1378ca0..39a799d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index df5f745..bbc68ec 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -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 diff --git a/backend/app/services/logo.py b/backend/app/services/logo.py new file mode 100644 index 0000000..9e62670 --- /dev/null +++ b/backend/app/services/logo.py @@ -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() diff --git a/backend/migrations/versions/0031_abo_logo.py b/backend/migrations/versions/0031_abo_logo.py new file mode 100644 index 0000000..19565df --- /dev/null +++ b/backend/migrations/versions/0031_abo_logo.py @@ -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") diff --git a/backend/tests/test_abos.py b/backend/tests/test_abos.py index a705a05..ad3180f 100644 --- a/backend/tests/test_abos.py +++ b/backend/tests/test_abos.py @@ -5,6 +5,7 @@ Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber Waehrungen und ueber verschiedene Intervalle hinweg. """ +import json import secrets from datetime import date, timedelta @@ -178,3 +179,122 @@ def test_angebotene_waehrungen_sind_eine_einstellung(admin): assert abo["currency"] == "USD" finally: 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" diff --git a/web/src/api.js b/web/src/api.js index b5be798..e9b320e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -272,6 +272,15 @@ export const api = { createSubscription: (body) => request("/subscriptions", { method: "POST", body }), updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }), 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"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), diff --git a/web/src/finance.css b/web/src/finance.css index d210af3..7c78d54 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1749,3 +1749,25 @@ tr.row-dup { .waehrung-kachel.an { border-color: var(--accent, var(--border-strong)); } .waehrung-kachel input { width: auto; margin: 0; } .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