Files
Vorrania/backend/app/routers/recipes.py
T
ScarriffleandClaude Opus 5 795e233a1b Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept
Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.

Zwei Tabellen dafuer, und die Trennung ist der Kern:

shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.

shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.

Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.

Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.

In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.

Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:30:00 +02:00

286 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 Group, MealieFoodMap, Product, User
from ..schemas import (
RecipeDetail,
RecipePage,
RecipeSummary,
ShoppingFromRecipe,
ZielVorschlag,
ZuordnungIn,
ZuordnungOut,
ZutatKandidat,
ZutatOut,
)
from ..services import einkaufsliste, mealie, rezepte, zuordnung
from ..services.conversion import group_min_context
from ..services.einkaufsliste_text import fehlmengen_text
from .shopping import eintrag_out
from ..services.matching import match_score, normalisieren
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)),
)
# --------------------------------------------------------------------------
# Zuordnungen von Hand
#
# ACHTUNG: Diese Routen stehen bewusst VOR ``/{slug}``. FastAPI probiert in
# Reihenfolge der Deklaration – stünden sie darunter, würde „zuordnungen" als
# Rezept-Slug gelesen und bei Mealie nachgefragt. ``test_zuordnung.py`` wacht
# darüber.
# --------------------------------------------------------------------------
@router.get("/zuordnungen", response_model=list[ZuordnungOut])
def list_zuordnungen(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ZuordnungOut]:
"""Alle gemerkten Zuordnungen – zum Nachsehen und Widerrufen."""
aus = []
for key, (art, ziel_id) in sorted(zuordnung.lade(db).items()):
zeile = db.get(MealieFoodMap, key)
aus.append(ZuordnungOut(
food_key=key,
food_name=(zeile.food_name if zeile is not None else key),
art=art, ziel_id=ziel_id,
ziel_name=zuordnung.ziel_name(db, art, ziel_id),
))
return aus
@router.get("/zuordnungen/ziele", response_model=list[ZielVorschlag])
def zuordnung_ziele(
q: str | None = None,
limit: int = 40,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ZielVorschlag]:
"""Artikel und Gruppen zur Auswahl, nach Suchtext eingegrenzt.
Anders als beim Raten wird hier **nicht** auf Lebensmittel eingeschränkt:
Die Heuristik soll keine Gegenstände vorschlagen, aber wer von Hand
zuordnet, weiss was er will – „Alufolie" steht in manchem Rezept. Nur
Einzelstücke fallen weg, die sind per Definition unverbrauchbar.
Mitgeliefert wird die Basiseinheit, damit man vor dem Zuordnen sieht, ob
das Ziel in Gramm oder Milliliter geführt wird.
"""
teile = normalisieren(q or "").split()
def passt(text: str) -> bool:
ziel = normalisieren(text)
return all(t in ziel for t in teile)
vorschlaege: list[ZielVorschlag] = []
for gruppe in db.query(Group).all():
if not passt(gruppe.name):
continue
ctx = group_min_context(gruppe)
vorschlaege.append(ZielVorschlag(
art="group", id=gruppe.id, name=gruppe.name, zusatz="Gruppe",
basis_einheit=(ctx.base_unit.value if ctx.base_unit else ""),
))
for produkt in db.query(Product).all():
if produkt.individual:
continue
voll = f"{produkt.name} {produkt.brand or ''}"
if not passt(voll):
continue
vorschlaege.append(ZielVorschlag(
art="product", id=produkt.id, name=produkt.name,
zusatz=(produkt.brand or ""),
basis_einheit=produkt.base_unit.value,
))
if teile:
vorschlaege.sort(key=lambda v: (-match_score(q or "", f"{v.name} {v.zusatz}"), v.name))
else:
vorschlaege.sort(key=lambda v: (0 if v.art == "group" else 1, v.name))
return vorschlaege[: max(1, min(limit, 200))]
@router.put("/zuordnungen", response_model=ZuordnungOut)
def setze_zuordnung(
payload: ZuordnungIn,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ZuordnungOut:
"""Eine Zutat von Hand zuordnen – gilt ab dann für jedes Rezept."""
try:
zeile = zuordnung.setze(db, payload.food, payload.art, payload.ziel_id)
except zuordnung.ZuordnungFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
return ZuordnungOut(
food_key=zeile.food_key, food_name=zeile.food_name,
art=payload.art, ziel_id=payload.ziel_id,
ziel_name=zuordnung.ziel_name(db, payload.art, payload.ziel_id),
)
@router.delete("/zuordnungen", status_code=status.HTTP_204_NO_CONTENT)
def loesche_zuordnung(
food: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
"""Zuordnung vergessen; danach wird für diese Zutat wieder geraten.
Der Name reist als Abfrageparameter, nicht im Pfad: Lebensmittelnamen
enthalten Schrägstriche („Öl/Fett") und würden den Pfad zerlegen.
"""
zuordnung.loesche(db, food)
@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, zuordnung=zuordnung.lade(db),
)
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],
)
@router.post("/{slug}/to-shopping-list", response_model=ShoppingFromRecipe)
def recipe_to_shopping_list(
slug: str,
servings: float | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ShoppingFromRecipe:
"""Was für dieses Rezept fehlt, auf die Einkaufsliste setzen.
Gerechnet wird dabei frisch — mit derselben Portionszahl, die auf dem
Bildschirm steht. Sonst landete die Menge für vier Portionen auf der Liste,
während die Seite zwölf anzeigt.
Übernommen wird die **Fehlmenge**, nicht die Rezeptmenge: Wer 250 g braucht
und 100 g hat, kauft 150 g. Zutaten ohne Zuordnung kommen als Freitext drauf
— gerade die unbekannte Zutat ist die, die man vergisst.
"""
try:
daten = mealie.rezept(db, slug)
except mealie.MealieFehler as fehler:
raise _fehler(fehler) from fehler
faktor, _ = rezepte.faktor_fuer_portionen(daten, servings)
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
pruefungen = rezepte.pruefe_zutaten(
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
)
eintraege, aktualisiert = einkaufsliste.aus_rezept(
db, pruefungen, slug=slug, name=str(daten.get("name") or slug), user=user,
)
return ShoppingFromRecipe(
entries=[eintrag_out(db, e) for e in eintraege],
aktualisiert=aktualisiert,
freitext=sum(1 for e in eintraege if e.free_text),
)
def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut:
return ZutatOut(
text=p.text, menge=p.menge, einheit=p.einheit,
food=p.food, manuell=p.manuell,
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),
)