Open Food Facts liefert nur eine Bildadresse. Direkt dorthin zu verlinken hiesse:
Das Bild verschwindet, wenn OFF es austauscht, die Installation braucht Internet,
und jeder Seitenaufruf verraet OFF, welche Artikel jemand ansieht.
Das Bild wird daher einmal geholt und in einer eigenen Tabelle product_images
abgelegt - eigene Tabelle, damit Artikellisten die Blobs nicht mitziehen.
Geholt wird beim Anlegen, bei geaenderter Bildadresse und beim ersten Abruf
(so bekommen auch bestehende Artikel ihre Kopie, ohne Wanderung ueber alle
Datensaetze).
GET /products/{id}/image verlangt eine Anmeldung - aus den Bildern liesse sich
sonst ohne Konto ablesen, was im Vorrat liegt. Da ein <img src> keinen
Authorization-Header schickt, laedt die Oberflaeche das Bild ueber fetch und
zeigt es als Objekt-URL.
Angezeigt wird es rechts neben Barcode, Name und Marke. Fehlt ein Bild, rendert
die Komponente nichts und die Felder nehmen die volle Breite ein.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""Artikelbilder holen und lokal vorhalten.
|
||
|
||
Open Food Facts liefert nur eine Bild-*Adresse*. Würde die Oberfläche direkt
|
||
dorthin verlinken, hinge jede Artikelseite an einem fremden Dienst: Das Bild
|
||
verschwindet, wenn OFF es austauscht oder nicht erreichbar ist, die Installation
|
||
funktioniert nicht mehr offline, und jeder Seitenaufruf verrät OFF, welche
|
||
Artikel jemand ansieht. Deshalb wird das Bild einmal geholt und danach aus der
|
||
eigenen Datenbank ausgeliefert.
|
||
|
||
Die Bilder liegen in einer eigenen Tabelle und nicht als Spalte an ``products``:
|
||
Sonst zöge jede Artikelliste die Blobs mit.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import Product, ProductImage
|
||
|
||
# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank
|
||
# mit einer versehentlich verlinkten Datei volllaeuft.
|
||
MAX_BYTES = 2 * 1024 * 1024
|
||
|
||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||
|
||
TIMEOUT_SECONDS = 15.0
|
||
|
||
|
||
def fetch(url: str) -> tuple[bytes, str] | None:
|
||
"""Bild herunterladen. Gibt (Daten, Inhaltstyp) zurück oder None.
|
||
|
||
Fehler sind hier bewusst kein Ausnahmefall, der nach oben durchschlägt: Ein
|
||
fehlendes Bild darf niemals das Anlegen eines Artikels verhindern.
|
||
"""
|
||
if not url or not url.startswith(("http://", "https://")):
|
||
return None
|
||
try:
|
||
resp = httpx.get(
|
||
url,
|
||
timeout=TIMEOUT_SECONDS,
|
||
follow_redirects=True,
|
||
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
|
||
)
|
||
except httpx.HTTPError:
|
||
return None
|
||
if resp.status_code != 200:
|
||
return None
|
||
|
||
typ = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||
if typ not in ALLOWED_TYPES:
|
||
return None
|
||
daten = resp.content
|
||
if not daten or len(daten) > MAX_BYTES:
|
||
return None
|
||
return daten, typ
|
||
|
||
|
||
def store(db: Session, product: Product, url: str) -> ProductImage | None:
|
||
"""Bild holen und beim Artikel ablegen. Ohne Erfolg bleibt alles wie es war."""
|
||
geholt = fetch(url)
|
||
if geholt is None:
|
||
return None
|
||
daten, typ = geholt
|
||
|
||
bild = db.get(ProductImage, product.id)
|
||
if bild is None:
|
||
bild = ProductImage(
|
||
product_id=product.id, content_type=typ, data=daten, source_url=url
|
||
)
|
||
db.add(bild)
|
||
else:
|
||
bild.content_type, bild.data, bild.source_url = typ, daten, url
|
||
return bild
|
||
|
||
|
||
def ensure(db: Session, product: Product) -> ProductImage | None:
|
||
"""Lokale Kopie zurückgeben und bei Bedarf einmalig nachholen.
|
||
|
||
So bekommen auch Artikel ein lokales Bild, die vor dieser Funktion angelegt
|
||
wurden – ohne Wanderung über alle Datensätze. Bezahlt wird das mit einer
|
||
einmaligen Verzögerung beim ersten Aufruf.
|
||
"""
|
||
bild = db.get(ProductImage, product.id)
|
||
if bild is not None:
|
||
return bild
|
||
if not product.image_url:
|
||
return None
|
||
bild = store(db, product, product.image_url)
|
||
if bild is not None:
|
||
db.commit()
|
||
return bild
|