Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.
Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.
Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").
Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.
Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.
Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.
Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.
16 neue Tests, Suite bei 487.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
8.8 KiB
Python
247 lines
8.8 KiB
Python
"""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,
|
||
ZielVorschlag,
|
||
ZuordnungIn,
|
||
ZuordnungOut,
|
||
ZutatKandidat,
|
||
ZutatOut,
|
||
)
|
||
from ..services import mealie, rezepte, zuordnung
|
||
from ..services.conversion import group_min_context
|
||
from ..services.einkaufsliste_text import fehlmengen_text
|
||
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],
|
||
)
|
||
|
||
|
||
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),
|
||
)
|