Files
ScarriffleandClaude Opus 5 18481bbca0 Seitenzustand in die Adresszeile
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>
2026-10-02 16:13:37 +02:00

296 lines
11 KiB
Python
Raw Permalink 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.
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),
)