Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.
useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.
Damit wandern in die URL:
- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
(?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.
Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.
Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.
Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 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,
|
||
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.
|
||
|
||
404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/<slug>): 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"
|
||
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),
|
||
)
|