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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user