From 0ba968985f0588583ae9899004b266ad8ec29cba Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 14:03:28 +0200 Subject: [PATCH] Bild uebernehmen: sichtbare Rueckmeldung und Nachholen bei fehlender Kopie Zwei Ursachen dafuer, dass der Knopf folgenlos wirkte. Sichtbar: Uebernehmen schreibt nur ins Formular, das Artikelbild wechselt erst beim Speichern - es gab aber nichts, woran man das erkannt haette. Die Spalte "Bisher" zeigt jetzt sofort das uebernommene Bild mit Kennzeichnung, der Knopf heisst danach "Uebernommen", und eine Meldung erklaert den naechsten Schritt. Tatsaechlich folgenlos war der haeufigste Fall: Stimmte die Bildadresse laengst und nur der Abruf war damals fehlgeschlagen, aenderte sich beim Speichern nichts - und die Bedingung fragte genau nach einer Aenderung. Sie richtet sich jetzt danach, woher die vorhandene Kopie stammt. Fehlt sie, wird geholt. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/products.py | 14 ++++++++------ backend/tests/test_product_images.py | 22 ++++++++++++++++++++++ web/src/components/OffVergleich.jsx | 19 ++++++++++++++++--- web/src/pages/ProductForm.jsx | 4 ++++ web/src/styles.css | 1 + 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 481b80b..9f0701c 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -251,19 +251,21 @@ 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. + if "image_url" in data: + # Massgeblich ist, woher die vorhandene Kopie stammt - nicht, ob sich + # die Adresse am Artikel geaendert hat. Sonst passierte genau dann + # nichts, wenn die Adresse schon stimmte, der Abruf damals aber + # fehlschlug: Der Artikel bliebe dauerhaft ohne Bild. alt = db.get(ProductImage, product.id) - if alt is not None: + if alt is not None and alt.source_url != product.image_url: db.delete(alt) db.flush() - if product.image_url: + alt = None + if alt is None and product.image_url: images.store(db, product, product.image_url) db.commit() db.refresh(product) diff --git a/backend/tests/test_product_images.py b/backend/tests/test_product_images.py index 211a5b4..4f3b155 100644 --- a/backend/tests/test_product_images.py +++ b/backend/tests/test_product_images.py @@ -99,6 +99,28 @@ def test_import_ueberschreibt_kein_vorhandenes_bild(db, user): assert vorhanden.image_url == "https://eigenes.example/bild.jpg" +def test_speichern_holt_nach_wenn_die_kopie_fehlt(db, user, monkeypatch): + """Der haeufigste Fall: Adresse stimmt laengst, der Abruf schlug nur einmal fehl. + + Wuerde nur auf eine geaenderte Adresse geachtet, bliebe so ein Artikel + dauerhaft ohne Bild - "Uebernehmen" waere dann folgenlos. + """ + from app.routers.products import update_product + from app.schemas import ProductUpdate + + monkeypatch.setattr(images, "fetch", lambda url: (EIN_PIXEL, "image/png")) + artikel = _artikel(db, "https://example.org/pesto.png") + assert db.get(ProductImage, artikel.id) is None + + update_product( + artikel.id, + ProductUpdate(image_url="https://example.org/pesto.png"), + db=db, + _=user, + ) + assert db.get(ProductImage, artikel.id) is not None + + def test_fetch_geht_nur_an_http_adressen(monkeypatch): """Ohne diese Schranke waere eine 'file://'-Adresse ein Weg ins Dateisystem.""" monkeypatch.setattr( diff --git a/web/src/components/OffVergleich.jsx b/web/src/components/OffVergleich.jsx index f4f223c..8d6f9d8 100644 --- a/web/src/components/OffVergleich.jsx +++ b/web/src/components/OffVergleich.jsx @@ -11,7 +11,8 @@ import ProduktBild from "./ProduktBild"; * felder: [{ schluessel, titel, eigen, fremd, anzeigeEigen?, anzeigeFremd? }] */ export default function OffVergleich({ - felder, bildEigenId, bildFremdUrl, onUebernehmen, onAlle, onSchliessen, + felder, bildEigenId, bildFremdUrl, bildUebernommen, + onUebernehmen, onAlle, onSchliessen, }) { const leer = (wert) => wert === null || wert === undefined || wert === ""; const abweichend = felder.filter((f) => !leer(f.fremd) && String(f.fremd) !== String(f.eigen)); @@ -62,7 +63,19 @@ export default function OffVergleich({ Bild - + + {/* Nach dem Übernehmen steht hier schon das neue Bild. Ohne das + sah der Knopf wirkungslos aus: Das Artikelbild wechselt erst + beim Speichern, weil der Server es dann erst holt. */} + {bildUebernommen + ? ( + + + neu + + ) + : } + {/* Vorschau kommt hier direkt von OFF – lokal gibt es das Bild ja gerade noch nicht. Übernommen und gespeichert, holt der @@ -74,7 +87,7 @@ export default function OffVergleich({ diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index c79dbc7..9e443cf 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -176,6 +176,9 @@ export default function ProductForm() { if (!offDaten) return; if (schluessel === "image_url") { set("image_url", offDaten.image_url || ""); + // Das Bild selbst wechselt erst beim Speichern - ohne Hinweis wirkt der + // Knopf folgenlos. + toast("Bild übernommen. Mit „Speichern“ wird es geholt."); return; } if (schluessel === "package_size") { @@ -384,6 +387,7 @@ export default function ProductForm() { felder={offFelder()} bildEigenId={id} bildFremdUrl={offDaten.image_url || ""} + bildUebernommen={Boolean(offDaten.image_url) && form.image_url === offDaten.image_url} onUebernehmen={uebernehmen} onAlle={alleUebernehmen} onSchliessen={() => setOffDaten(null)} diff --git a/web/src/styles.css b/web/src/styles.css index 5b2d9f0..fdcfe73 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -230,6 +230,7 @@ input::placeholder { color: var(--muted); opacity: 0.7; } width: 56px; aspect-ratio: 3 / 4; object-fit: contain; background: var(--surface-2); border-radius: 4px; display: block; } +.off-neu { display: flex; align-items: center; gap: var(--sp-2); } .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. */