Artikelbilder lokal speichern und auf der Artikelseite anzeigen
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>
This commit is contained in:
@@ -277,6 +277,29 @@ class Movement(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
"""Lokale Kopie des Artikelbilds.
|
||||
|
||||
Eigene Tabelle statt einer Spalte an ``products``: Sonst zöge jede
|
||||
Artikelliste die Bilddaten mit, obwohl sie dort niemand braucht.
|
||||
|
||||
``source_url`` merkt sich, woher das Bild kam – daran ist erkennbar, ob eine
|
||||
geänderte ``Product.image_url`` ein neues Bild bedeutet.
|
||||
"""
|
||||
|
||||
__tablename__ = "product_images"
|
||||
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class BrandingAsset(Base):
|
||||
"""Eigenes Logo bzw. Favicon der Installation.
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import product_to_out
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Barcode, BaseUnit, Category, Group, Product, User
|
||||
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||
from ..services import images
|
||||
from ..services.categories import suggest_category
|
||||
from .categories import descendant_ids
|
||||
from ..services.conversion import ConversionError, resolve_product_unit
|
||||
@@ -90,6 +92,34 @@ def get_product(
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.get("/{product_id}/image")
|
||||
def get_product_image(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Artikelbild aus der eigenen Datenbank – nie von Open Food Facts.
|
||||
|
||||
Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern
|
||||
liesse sich sonst ohne Konto ablesen, was im Vorrat liegt.
|
||||
"""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
bild = images.ensure(db, product)
|
||||
if bild is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden")
|
||||
return Response(
|
||||
content=bild.data,
|
||||
media_type=bild.content_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"ETag": f'"bild-{product_id}-{int(bild.updated_at.timestamp())}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(
|
||||
payload: ProductCreate,
|
||||
@@ -129,6 +159,10 @@ def create_product(
|
||||
db.add(product)
|
||||
db.flush() # product.id fuer den Gruppen-Code
|
||||
sync_group_code(db, product)
|
||||
if product.image_url:
|
||||
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
|
||||
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
|
||||
images.store(db, product, product.image_url)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
@@ -173,10 +207,20 @@ def update_product(
|
||||
data.pop("date_precision", None) # Spalte ist NOT NULL
|
||||
else:
|
||||
data["date_precision"] = data["date_precision"].value
|
||||
alte_bildadresse = product.image_url
|
||||
for field, value in data.items():
|
||||
setattr(product, field, value)
|
||||
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||||
sync_group_code(db, product)
|
||||
if "image_url" in data and data["image_url"] != alte_bildadresse:
|
||||
# Neue Adresse heisst neues Bild – die alte Kopie waere sonst dauerhaft
|
||||
# falsch, weil ``ensure`` nur nachlaedt, wenn gar keine Kopie da ist.
|
||||
alt = db.get(ProductImage, product.id)
|
||||
if alt is not None:
|
||||
db.delete(alt)
|
||||
db.flush()
|
||||
if product.image_url:
|
||||
images.store(db, product, product.image_url)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
|
||||
92
backend/app/services/images.py
Normal file
92
backend/app/services/images.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""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
|
||||
53
backend/tests/test_product_images.py
Normal file
53
backend/tests/test_product_images.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from app.models import BaseUnit, Product, ProductImage
|
||||
from app.services import images
|
||||
|
||||
EIN_PIXEL = b"\x89PNG\r\n\x1a\n-nicht-echt-aber-egal"
|
||||
|
||||
|
||||
def _artikel(db, url=None):
|
||||
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, image_url=url)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch):
|
||||
monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError))
|
||||
assert images.ensure(db, _artikel(db)) is None
|
||||
|
||||
|
||||
def test_bild_wird_einmal_geholt_und_danach_lokal_geliefert(db, monkeypatch):
|
||||
aufrufe = []
|
||||
|
||||
def gefaelscht(url):
|
||||
aufrufe.append(url)
|
||||
return EIN_PIXEL, "image/png"
|
||||
|
||||
monkeypatch.setattr(images, "fetch", gefaelscht)
|
||||
artikel = _artikel(db, "https://example.org/pesto.png")
|
||||
|
||||
erstes = images.ensure(db, artikel)
|
||||
assert erstes is not None
|
||||
assert erstes.data == EIN_PIXEL
|
||||
assert erstes.content_type == "image/png"
|
||||
|
||||
# Zweiter Aufruf darf nicht erneut ins Netz gehen - genau das ist der Zweck.
|
||||
images.ensure(db, artikel)
|
||||
assert aufrufe == ["https://example.org/pesto.png"]
|
||||
|
||||
|
||||
def test_ein_nicht_erreichbares_bild_bleibt_folgenlos(db, monkeypatch):
|
||||
monkeypatch.setattr(images, "fetch", lambda url: None)
|
||||
artikel = _artikel(db, "https://example.org/weg.png")
|
||||
assert images.ensure(db, artikel) is None
|
||||
assert db.get(ProductImage, artikel.id) is None
|
||||
|
||||
|
||||
def test_fetch_geht_nur_an_http_adressen(monkeypatch):
|
||||
"""Ohne diese Schranke waere eine 'file://'-Adresse ein Weg ins Dateisystem."""
|
||||
monkeypatch.setattr(
|
||||
images.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError)
|
||||
)
|
||||
assert images.fetch("file:///etc/passwd") is None
|
||||
assert images.fetch("") is None
|
||||
@@ -82,6 +82,25 @@ function zeitstempel() {
|
||||
+ `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild mit Anmeldung laden und als lokale Objekt-URL zurückgeben.
|
||||
*
|
||||
* Nötig, weil ein <img src="…"> keinen Authorization-Header mitschickt. Ohne
|
||||
* diesen Umweg müsste die Bildroute offen sein – dann liesse sich ohne Konto
|
||||
* ablesen, was im Vorrat liegt.
|
||||
*
|
||||
* Gibt null zurück, wenn es kein Bild gibt; das ist der Normalfall und kein
|
||||
* Fehler. Wer die URL nicht mehr braucht, gibt sie mit URL.revokeObjectURL frei.
|
||||
*/
|
||||
export async function authorizedObjectUrl(path) {
|
||||
const token = getToken();
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return URL.createObjectURL(await resp.blob());
|
||||
}
|
||||
|
||||
// Datei mit Auth-Header laden und im Browser als Download anbieten.
|
||||
export async function downloadFile(path, filename) {
|
||||
const token = getToken();
|
||||
|
||||
42
web/src/components/ProduktBild.jsx
Normal file
42
web/src/components/ProduktBild.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { authorizedObjectUrl } from "../api";
|
||||
|
||||
/**
|
||||
* Artikelbild aus der eigenen Datenbank.
|
||||
*
|
||||
* Rendert nichts, solange kein Bild vorliegt – ein Platzhalter für einen
|
||||
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
|
||||
*
|
||||
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
|
||||
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
|
||||
*/
|
||||
export default function ProduktBild({ productId, alt, className = "" }) {
|
||||
const [url, setUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return undefined;
|
||||
let abgebrochen = false;
|
||||
let erzeugt = null;
|
||||
|
||||
authorizedObjectUrl(`/products/${productId}/image`)
|
||||
.then((neu) => {
|
||||
if (!neu) return;
|
||||
if (abgebrochen) {
|
||||
URL.revokeObjectURL(neu);
|
||||
return;
|
||||
}
|
||||
erzeugt = neu;
|
||||
setUrl(neu);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
if (erzeugt) URL.revokeObjectURL(erzeugt);
|
||||
setUrl(null);
|
||||
};
|
||||
}, [productId]);
|
||||
|
||||
if (!url) return null;
|
||||
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import BarcodeList from "../components/BarcodeList";
|
||||
import Icon from "../components/Icon";
|
||||
import ProduktBild from "../components/ProduktBild";
|
||||
import { useToast } from "../toast";
|
||||
import { useSettings } from "../settings";
|
||||
import { asTree } from "../categoryTree";
|
||||
@@ -263,6 +264,12 @@ export default function ProductForm() {
|
||||
|
||||
<div className="grid-2">
|
||||
<form className="card" onSubmit={save}>
|
||||
{/* Bild steht neben den Kennfeldern, nicht darueber: Der Platz rechts
|
||||
von Barcode, Name und Marke ist ohnehin selten ausgenutzt. Fehlt
|
||||
ein Bild, rendert die Komponente nichts und die Felder nehmen die
|
||||
volle Breite ein. */}
|
||||
<div className="produkt-kopf">
|
||||
<div className="produkt-kopf-felder">
|
||||
<div className="field-inline">
|
||||
<label className="grow">
|
||||
Barcode
|
||||
@@ -282,6 +289,9 @@ export default function ProductForm() {
|
||||
Marke
|
||||
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
|
||||
</label>
|
||||
</div>
|
||||
{!isNew && <ProduktBild productId={id} alt={form.name} />}
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Einheit
|
||||
|
||||
@@ -189,6 +189,25 @@ input:focus, select:focus {
|
||||
input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||
.row { display: flex; gap: var(--sp-3); align-items: flex-start; }
|
||||
.row .grow { flex: 1; }
|
||||
|
||||
/* Artikelbild neben den Kennfeldern. min-width:0 an der Feldspalte, sonst
|
||||
drueckt ein langer Produktname die Eingaben aus der Karte heraus. */
|
||||
.produkt-kopf { display: flex; gap: var(--sp-4); align-items: flex-start; }
|
||||
.produkt-kopf-felder { flex: 1 1 auto; min-width: 0; }
|
||||
.produkt-bild {
|
||||
flex: 0 0 auto;
|
||||
width: 132px; height: 132px;
|
||||
object-fit: contain; /* Verpackungen nie beschneiden */
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
/* Auf schmalen Fenstern haben Bild und Felder nebeneinander keinen Platz mehr. */
|
||||
@media (max-width: 560px) {
|
||||
.produkt-kopf { flex-direction: column-reverse; }
|
||||
.produkt-bild { width: 100%; height: 160px; }
|
||||
}
|
||||
.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); }
|
||||
/* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst
|
||||
rutschen daneben stehende Buttons optisch nach unten. */
|
||||
|
||||
Reference in New Issue
Block a user