Rezepte aus Mealie gegen den Bestand pruefen
Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.
Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.
Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):
1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
"nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
Kategorie, meldet die Zutat genau das zurueck (Massname und
Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.
Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.
Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:
- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
statt mit der rohen Schema-Meldung "String should have at least 1
character", die niemandem sagt, welches Feld gemeint ist.
Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7d8a54a3fc
commit
2247c8cf69
@@ -0,0 +1,129 @@
|
||||
"""Rezepte aus Mealie, gegen den eigenen Bestand geprüft.
|
||||
|
||||
Die Seite beantwortet eine Frage: Kann ich das heute kochen, oder muss ich
|
||||
vorher einkaufen? Das Rechnen steckt in ``services/rezepte.py`` (ohne Netz),
|
||||
das Holen in ``services/mealie.py`` (ohne Rechnen).
|
||||
"""
|
||||
|
||||
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import User
|
||||
from ..schemas import (
|
||||
RecipeDetail,
|
||||
RecipePage,
|
||||
RecipeSummary,
|
||||
ZutatKandidat,
|
||||
ZutatOut,
|
||||
)
|
||||
from ..services import mealie, rezepte
|
||||
from ..services.einkaufsliste_text import fehlmengen_text
|
||||
|
||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
|
||||
|
||||
def _fehler(fehler: mealie.MealieFehler) -> HTTPException:
|
||||
"""Mealie-Kennung auf einen Statuscode abbilden.
|
||||
|
||||
503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" –
|
||||
die Oberfläche soll beides verschieden erklären können.
|
||||
"""
|
||||
code = (
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
if fehler.kennung == "nicht_eingerichtet"
|
||||
else status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
return HTTPException(code, str(fehler))
|
||||
|
||||
|
||||
def _bild(eintrag: dict, basis: str) -> str | None:
|
||||
"""Mealie liefert Bilder unter /api/media/recipes/<id>/images/min-original.webp."""
|
||||
kennung = eintrag.get("id")
|
||||
if not kennung or not basis:
|
||||
return None
|
||||
return f"{basis}/api/media/recipes/{kennung}/images/min-original.webp"
|
||||
|
||||
|
||||
@router.get("", response_model=RecipePage)
|
||||
def list_recipes(
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RecipePage:
|
||||
"""Rezeptliste aus Mealie, blätterbar und durchsuchbar."""
|
||||
try:
|
||||
daten = mealie.rezepte(db, seite=page, pro_seite=per_page, suche=q)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise _fehler(fehler) from fehler
|
||||
basis = mealie.basis_adresse(db)
|
||||
eintraege = [
|
||||
RecipeSummary(
|
||||
slug=str(e.get("slug") or ""),
|
||||
name=str(e.get("name") or e.get("slug") or "Ohne Titel"),
|
||||
description=str(e.get("description") or "")[:300],
|
||||
image_url=_bild(e, basis),
|
||||
servings=e.get("recipeServings") or e.get("recipe_servings") or None,
|
||||
)
|
||||
for e in (daten.get("items") or [])
|
||||
if e.get("slug")
|
||||
]
|
||||
return RecipePage(
|
||||
items=eintraege, page=page, per_page=per_page,
|
||||
total=int(daten.get("total") or len(eintraege)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=RecipeDetail)
|
||||
def get_recipe(
|
||||
slug: str,
|
||||
servings: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RecipeDetail:
|
||||
"""Ein Rezept mit allen Zutaten, auf ``servings`` Portionen hochgerechnet."""
|
||||
try:
|
||||
daten = mealie.rezept(db, slug)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise _fehler(fehler) from fehler
|
||||
|
||||
faktor, hinweis = rezepte.faktor_fuer_portionen(daten, servings)
|
||||
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
|
||||
pruefungen = rezepte.pruefe_zutaten(db, zutaten, faktor=faktor)
|
||||
|
||||
basis_portionen = (
|
||||
daten.get("recipeServings") or daten.get("recipe_servings")
|
||||
or daten.get("recipeYieldQuantity") or daten.get("recipe_yield_quantity") or None
|
||||
)
|
||||
return RecipeDetail(
|
||||
slug=slug,
|
||||
name=str(daten.get("name") or slug),
|
||||
description=str(daten.get("description") or ""),
|
||||
image_url=_bild(daten, mealie.basis_adresse(db)),
|
||||
base_servings=basis_portionen,
|
||||
servings=servings or basis_portionen,
|
||||
skalierung_hinweis=hinweis,
|
||||
kochbar=rezepte.kochbar(pruefungen),
|
||||
ingredients=[_zutat_out(db, p) for p in pruefungen],
|
||||
)
|
||||
|
||||
|
||||
def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut:
|
||||
return ZutatOut(
|
||||
text=p.text, menge=p.menge, einheit=p.einheit,
|
||||
art=p.art, ziel_id=p.ziel_id, ziel_name=p.ziel_name,
|
||||
kandidaten=[
|
||||
ZutatKandidat(art=k.art, id=k.id, name=k.name, score=k.score)
|
||||
for k in p.kandidaten
|
||||
],
|
||||
benoetigt_base=p.benoetigt_base, bestand_base=p.bestand_base,
|
||||
fehlt_base=p.fehlt_base, basis_einheit=p.basis_einheit,
|
||||
status=p.status, hinweis=p.hinweis,
|
||||
fehlendes_mass=p.fehlendes_mass, kategorie_id=p.kategorie_id,
|
||||
fehlt_text=fehlmengen_text(db, p),
|
||||
)
|
||||
Reference in New Issue
Block a user