diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py index c05096b..4f47dc2 100644 --- a/backend/app/routers/recipes.py +++ b/backend/app/routers/recipes.py @@ -38,7 +38,17 @@ def _fehler(fehler: mealie.MealieFehler) -> HTTPException: 503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" – die Oberfläche soll beides verschieden erklären können. + + 404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/): Seither + lässt sich ein Rezept verlinken, und ein alter Link zeigt irgendwann ins + Leere. Das ist kein Gateway-Fehler, sondern eine ganz gewöhnliche Auskunft – + und sie soll auch so klingen, nicht nach „Mealie kennt /api/recipes/x nicht". """ + if fehler.kennung == "nicht_gefunden": + return HTTPException( + status.HTTP_404_NOT_FOUND, + "Dieses Rezept gibt es in Mealie nicht (mehr).", + ) code = ( status.HTTP_503_SERVICE_UNAVAILABLE if fehler.kennung == "nicht_eingerichtet" diff --git a/backend/tests/test_mealie.py b/backend/tests/test_mealie.py index ed77a44..f0e07a3 100644 --- a/backend/tests/test_mealie.py +++ b/backend/tests/test_mealie.py @@ -18,6 +18,7 @@ from app.routers.mealie import ( mealie_token_setzen, mealie_trennen, ) +from app.routers.recipes import get_recipe from app.routers.settings import list_settings from app.schemas import MealieConfigIn, MealieTokenIn from app.services import mealie @@ -301,3 +302,24 @@ def test_pfadvariante_wird_gepuffert(db, admin): mealie.pfadvariante(db) assert len(gesehen) == anzahl # keine zweite Anfrage + + +def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin): + """Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen. + + Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text + landet in der Oberflaeche, darf also nicht nach Innenleben klingen. + """ + db.add(Setting(key="mealie_base_url", value="https://m.example.com")) + db.add(IntegrationSecret(service="mealie", secret="t")) + db.commit() + mealie.setze_transport(httpx.MockTransport( + lambda request: httpx.Response(404, json={"detail": "Not found"}), + )) + + with pytest.raises(HTTPException) as fehler: + get_recipe("gibtsnicht", db=db, _=admin) + + assert fehler.value.status_code == 404 + assert "Rezept" in str(fehler.value.detail) + assert "/api/" not in str(fehler.value.detail) diff --git a/web/src/App.jsx b/web/src/App.jsx index 5722edf..aca0152 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -266,6 +266,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- und Auslagern, keine Stammdatenänderung. */} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index e971b50..ddc493c 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useUrlWerte } from "../urlState"; import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; @@ -22,12 +23,28 @@ export default function Categories() { const [categories, setCategories] = useState([]); const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" }); const [error, setError] = useState(null); - // Kategorie, deren Felder gerade verwaltet werden. - const [feldKat, setFeldKat] = useState(null); - // Welche Kategorie gerade ihre Masse und ihre Oeffnungsfrist zeigt. - const [massKat, setMassKat] = useState(null); - // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. - const [typFilter, setTypFilter] = useState(""); + // Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der + // URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert + // wird die Kennung, nicht das Objekt - die Kategorien kommen erst vom + // Server, und ein Objekt laesst sich nicht in eine Adresse schreiben. + const [{ kat, panel, typ: typFilter }, setzeAnsicht] = useUrlWerte( + { kat: "", panel: "", typ: "" }, + ); + const katId = Number(kat) || null; + const gewaehlt = categories.find((c) => c.id === katId) ?? null; + const feldKat = panel === "felder" ? gewaehlt : null; + const massKat = panel === "masse" ? gewaehlt : null; + + // Ein Klick aendert zwei Werte - deshalb EIN Schreibvorgang, sonst + // ueberschreibt der zweite den ersten. + function zeigeKarte(c, welche) { + const schonOffen = katId === c.id && panel === welche; + setzeAnsicht(schonOffen ? { kat: "", panel: "" } : { kat: c.id, panel: welche }); + } + function schliesseKarte() { + setzeAnsicht({ kat: "", panel: "" }); + } + const setTypFilter = (wert) => setzeAnsicht({ typ: wert }); const [loading, setLoading] = useState(true); async function load() { @@ -82,7 +99,7 @@ export default function Categories() { if (!ok) return; try { await api.deleteCategory(category.id); - if (feldKat && feldKat.id === category.id) setFeldKat(null); + if (katId === category.id) schliesseKarte(); load(); } catch (err) { setError(err.message); @@ -137,11 +154,11 @@ export default function Categories() { render: (c) => ( {isAdmin && ( @@ -225,7 +242,7 @@ export default function Categories() { key={feldKat.id} category={feldKat} isAdmin={isAdmin} - onClose={() => setFeldKat(null)} + onClose={schliesseKarte} /> )} @@ -234,7 +251,7 @@ export default function Categories() { key={massKat.id} category={massKat} isAdmin={isAdmin} - onClose={() => setMassKat(null)} + onClose={schliesseKarte} onFrist={() => load()} /> )} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 2428137..329fb57 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { api } from "../api"; +import { useUrlZahl } from "../urlState"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { ProduktThumb } from "../components/ProduktBild"; @@ -18,6 +19,11 @@ export default function CheckIn() { const { isAdmin } = useAuth(); const toast = useToast(); const [barcode, setBarcode] = useState(""); + // Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst + // bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es + // jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein + // Link "lager diesen Artikel ein" laesst sich verschicken. + const [artikelId, setArtikelId] = useUrlZahl("artikel"); const [product, setProduct] = useState(null); const [suggestion, setSuggestion] = useState(null); // Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl"). @@ -59,6 +65,17 @@ export default function CheckIn() { if (loc) setLocationId(loc); }, [searchParams]); + // Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der + // Kennung in selectProduct den Artikel gleich noch einmal laedt. + useEffect(() => { + if (!artikelId) { setProduct(null); return; } + if (product && product.id === artikelId) return; + api.getProduct(artikelId) + .then(selectProduct) + .catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artikelId]); + function resetLookup() { setSuggestion(null); setUnknownBarcode(null); @@ -67,6 +84,7 @@ export default function CheckIn() { function selectProduct(p) { setProduct(p); + setArtikelId(p.id); setResults([]); resetLookup(); const opts = buildUnitOptions(p, units); @@ -375,7 +393,7 @@ export default function CheckIn() {
{product.name}
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
- +
diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index 7634c0d..04a61a9 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useUrlZahl } from "../urlState"; import Icon from "../components/Icon"; import { ProduktThumb } from "../components/ProduktBild"; import { useToast } from "../toast"; @@ -12,6 +13,11 @@ export default function CheckOut() { const [barcode, setBarcode] = useState(""); const [search, setSearch] = useState(""); const [results, setResults] = useState([]); + // Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst + // bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es + // jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein + // Link "lager diesen Artikel ein" laesst sich verschicken. + const [artikelId, setArtikelId] = useUrlZahl("artikel"); const [product, setProduct] = useState(null); const [units, setUnits] = useState([]); const [lots, setLots] = useState([]); @@ -27,8 +33,20 @@ export default function CheckOut() { api.listUnits().then(setUnits).catch(() => {}); }, []); + // Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der + // Kennung in selectProduct den Artikel gleich noch einmal laedt. + useEffect(() => { + if (!artikelId) { setProduct(null); return; } + if (product && product.id === artikelId) return; + api.getProduct(artikelId) + .then(selectProduct) + .catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artikelId]); + async function selectProduct(p) { setProduct(p); + setArtikelId(p.id); setResults([]); setLotId(""); setQuantity(""); @@ -155,7 +173,7 @@ export default function CheckOut() { )}
- +