"""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//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), )