Zutaten von Hand zuordnen - und Vorrania merkt es sich

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>
This commit is contained in:
Scarriffle
2026-10-02 13:40:23 +02:00
co-authored by Claude Opus 5
parent 2a56cca3c8
commit 8072e7f566
9 changed files with 773 additions and 3 deletions
+36
View File
@@ -815,6 +815,42 @@ class IntegrationSecret(Base):
)
class MealieFoodMap(Base):
"""Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y.
Der unscharfe Abgleich in ``services/rezepte.py`` raet, dieser Tisch weiss.
Jede Korrektur von Hand landet hier und gilt ab dann fuer jedes Rezept, in
dem dasselbe Lebensmittel vorkommt — eine Zutat wird also hoechstens einmal
im Leben zugeordnet.
Schluessel ist der **normalisierte** Name (``matching.normalisieren``), damit
"Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " dieselbe Zeile treffen.
Daneben steht der Name, wie Mealie ihn schreibt — nur zur Anzeige in der
Liste der gemerkten Zuordnungen.
Genau eines von ``product_id`` und ``group_id`` ist gesetzt; geprueft wird
das beim Speichern (``services/zuordnung.py``), nicht in der Datenbank —
dasselbe Muster wie bei den Bedarfszeilen. Verschwindet das Ziel spaeter,
wird die Zeile beim Laden stillschweigend uebersprungen: Auf SQLite greift
kein Fremdschluessel, und eine Zuordnung auf einen geloeschten Artikel darf
den Rezeptabgleich nicht sprengen.
"""
__tablename__ = "mealie_food_map"
food_key: Mapped[str] = mapped_column(String(200), primary_key=True)
food_name: Mapped[str] = mapped_column(String(200), nullable=False)
product_id: Mapped[int | None] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
+120 -3
View File
@@ -12,16 +12,21 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import User
from ..models import Group, MealieFoodMap, Product, User
from ..schemas import (
RecipeDetail,
RecipePage,
RecipeSummary,
ZielVorschlag,
ZuordnungIn,
ZuordnungOut,
ZutatKandidat,
ZutatOut,
)
from ..services import mealie, rezepte
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"])
@@ -79,6 +84,115 @@ def list_recipes(
)
# --------------------------------------------------------------------------
# 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,
@@ -94,7 +208,9 @@ def get_recipe(
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)
pruefungen = rezepte.pruefe_zutaten(
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
)
basis_portionen = (
daten.get("recipeServings") or daten.get("recipe_servings")
@@ -116,6 +232,7 @@ def get_recipe(
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)
+31
View File
@@ -756,6 +756,10 @@ class ZutatOut(BaseModel):
text: str
menge: float | None = None
einheit: str = ""
# Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann.
food: str = ""
# True = so zugeordnet, weil es jemand einmal von Hand entschieden hat.
manuell: bool = False
art: str | None = None
ziel_id: int | None = None
ziel_name: str = ""
@@ -786,6 +790,33 @@ class RecipeDetail(BaseModel):
ingredients: list[ZutatOut] = []
# ---- Gemerkte Zutaten-Zuordnungen ----
class ZuordnungIn(BaseModel):
"""Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen."""
food: str = Field(min_length=1, max_length=200)
art: str = Field(pattern="^(product|group)$")
ziel_id: int
class ZuordnungOut(BaseModel):
"""Eine gemerkte Zuordnung, wie sie in der Liste steht."""
food_key: str
food_name: str
art: str
ziel_id: int
ziel_name: str = ""
class ZielVorschlag(BaseModel):
"""Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen."""
art: str
id: int
name: str
# Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie.
zusatz: str = ""
basis_einheit: str = ""
# ---- Hausmaße ----
class MeasureIn(BaseModel):
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
+7
View File
@@ -61,6 +61,11 @@ class ZutatPruefung:
text: str # wie das Rezept es schreibt
menge: float | None = None # skalierte Menge in der Rezept-Einheit
einheit: str = ""
# Der reine Lebensmittelname aus Mealie – Schluessel fuer die gemerkte
# Zuordnung von Hand (``services/zuordnung.py``).
food: str = ""
# True = die Zuordnung kommt aus einer gemerkten Entscheidung, nicht vom Raten.
manuell: bool = False
# Zuordnung
art: str | None = None # "product" | "group"
ziel_id: int | None = None
@@ -349,6 +354,7 @@ def pruefe_zutaten(
for zutat in zutaten:
pruefung = ZutatPruefung(text=_zutat_text(zutat))
name = _lebensmittel(zutat)
pruefung.food = name
pruefung.einheit = _einheit(zutat)
rohmenge = zutat.get("quantity")
@@ -364,6 +370,7 @@ def pruefe_zutaten(
gemerkt = zuordnung.get(normalisieren(name))
if gemerkt is not None:
pruefung.art, pruefung.ziel_id = gemerkt
pruefung.manuell = True
else:
pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
+94
View File
@@ -0,0 +1,94 @@
"""Gemerkte Zuordnungen: Mealie-Lebensmittel → eigener Artikel oder Gruppe.
Der unscharfe Abgleich in :mod:`services.rezepte` ist eine Heuristik und wird
immer Fälle haben, die er nicht erraten kann — „Lime Curd", „Ras el Hanout",
oder einfach ein Hausname. Hier liegt der Korrekturweg: Einmal von Hand
zugeordnet, gilt es für jedes Rezept, in dem dasselbe Lebensmittel vorkommt.
Der Schlüssel ist der normalisierte Name, nicht die Mealie-ID. Mealie-IDs sind
UUIDs, die beim Neuanlegen eines Lebensmittels wechseln; der Name bleibt. Und
nur über den Namen greift die Zuordnung auch für Zutaten, die in Mealie gar
kein verknüpftes Lebensmittel haben (freier Text „2 Eier").
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from ..models import Group, MealieFoodMap, Product
from .matching import normalisieren
class ZuordnungFehler(ValueError):
"""Unbrauchbare Zuordnung – der Router macht daraus eine 400/404."""
def schluessel(name: str) -> str:
"""Normalisierter Schlüssel. Leer heisst „damit lässt sich nichts merken"."""
return normalisieren(name)[:200]
def lade(db: Session) -> dict[str, tuple[str, int]]:
"""Alle gültigen Zuordnungen als ``{schluessel: (art, id)}``.
Zeilen, deren Ziel es nicht mehr gibt, fallen hier still heraus statt eine
Ausnahme zu werfen: Auf SQLite greift kein Fremdschlüssel, und ein
gelöschter Artikel darf nicht dazu führen, dass sich kein Rezept mehr
öffnen lässt.
"""
ergebnis: dict[str, tuple[str, int]] = {}
for zeile in db.query(MealieFoodMap).all():
if zeile.product_id is not None:
if db.get(Product, zeile.product_id) is not None:
ergebnis[zeile.food_key] = ("product", zeile.product_id)
elif zeile.group_id is not None:
if db.get(Group, zeile.group_id) is not None:
ergebnis[zeile.food_key] = ("group", zeile.group_id)
return ergebnis
def ziel_name(db: Session, art: str, ziel_id: int) -> str:
if art == "product":
produkt = db.get(Product, ziel_id)
return produkt.name if produkt is not None else ""
gruppe = db.get(Group, ziel_id)
return gruppe.name if gruppe is not None else ""
def setze(db: Session, food: str, art: str, ziel_id: int) -> MealieFoodMap:
"""Zuordnung anlegen oder ändern. Prüft, dass das Ziel existiert."""
key = schluessel(food)
if not key:
raise ZuordnungFehler(
"Zu dieser Zutat steht kein Name da – ohne Namen lässt sich die "
"Zuordnung nicht merken."
)
if art not in ("product", "group"):
raise ZuordnungFehler("Zugeordnet wird auf einen Artikel oder eine Gruppe.")
if art == "product" and db.get(Product, ziel_id) is None:
raise ZuordnungFehler("Diesen Artikel gibt es nicht.")
if art == "group" and db.get(Group, ziel_id) is None:
raise ZuordnungFehler("Diese Gruppe gibt es nicht.")
zeile = db.get(MealieFoodMap, key)
if zeile is None:
zeile = MealieFoodMap(food_key=key)
db.add(zeile)
# Immer beide Felder setzen, damit ein Wechsel Artikel → Gruppe nicht
# beide gefüllt zurücklässt.
zeile.food_name = (food or "").strip()[:200]
zeile.product_id = ziel_id if art == "product" else None
zeile.group_id = ziel_id if art == "group" else None
db.commit()
db.refresh(zeile)
return zeile
def loesche(db: Session, food: str) -> bool:
"""Zuordnung vergessen. ``False``, wenn es keine gab."""
zeile = db.get(MealieFoodMap, schluessel(food))
if zeile is None:
return False
db.delete(zeile)
db.commit()
return True
+305
View File
@@ -0,0 +1,305 @@
"""Zutaten von Hand zuordnen – und dass sich Vorrania das merkt.
Der unscharfe Abgleich wird immer Zutaten haben, die er nicht erraten kann
(„Lime Curd") oder falsch errät. Hier liegt der Korrekturweg: einmal von Hand
entschieden, gilt es für jedes Rezept.
"""
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Group,
MealieFoodMap,
Product,
Role,
User,
)
from app.routers.recipes import (
list_zuordnungen,
loesche_zuordnung,
router,
setze_zuordnung,
zuordnung_ziele,
)
from app.schemas import ZuordnungIn
from app.services import zuordnung
from app.services.rezepte import pruefe_zutaten
from app.services.stock import check_in
@pytest.fixture()
def nutzer(db):
person = User(username="koch", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _zutat(name, menge=None, einheit=None):
return {
"quantity": menge,
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
"food": {"name": name},
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
}
def _kategorie(db, name, tracking=CategoryTracking.food):
cat = Category(name=name, tracking=tracking.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, bestand=0.0, **extra):
product = Product(name=name, base_unit=base, package_size=1000,
category_id=cat.id, source="manual", **extra)
db.add(product)
db.commit()
db.refresh(product)
if bestand:
check_in(db, product, bestand, base.value, None, None, None)
db.commit()
return product
# --------------------------------------------------------------------------
# Die Zuordnung greift
# --------------------------------------------------------------------------
def test_gemerkte_zuordnung_findet_was_das_raten_nicht_findet(db, nutzer):
"""„Lime Curd" hat mit „Zitronencreme" kein Wort gemeinsam."""
cat = _kategorie(db, "Aufstriche")
creme = _artikel(db, "Zitronencreme", cat, bestand=500)
[vorher] = pruefe_zutaten(db, [_zutat("Lime Curd", 100, "g")])
assert vorher.status == "kein_treffer"
setze_zuordnung(
ZuordnungIn(food="Lime Curd", art="product", ziel_id=creme.id),
db=db, _=nutzer,
)
[nachher] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert nachher.status == "vorhanden"
assert nachher.ziel_name == "Zitronencreme"
assert nachher.manuell is True
def test_zuordnung_schlaegt_einen_falschen_treffer(db, nutzer):
"""Der Kern der Beschwerde: Sahne ist kein Kräuter-Sahne-Hähnchen."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Kräuter Sahne Hähnchen", cat, bestand=2000)
sahne = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Sahne", art="product", ziel_id=sahne.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Sahne", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == sahne.id
assert zutat.status == "vorhanden"
def test_gross_und_kleinschreibung_treffen_dieselbe_zeile(db, nutzer):
cat = _kategorie(db, "Kuehlregal")
rahm = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Schlagsahne", art="product", ziel_id=rahm.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat(" SCHLAGSAHNE ", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == rahm.id
def test_zuordnung_auf_eine_gruppe(db, nutzer):
"""„Milch" ist markenübergreifend gemeint – dafür gibt es Gruppen."""
cat = _kategorie(db, "Kuehlregal")
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
_artikel(db, "H-Milch 3,5%", cat, base=BaseUnit.milliliter,
bestand=2000, group_id=gruppe.id)
setze_zuordnung(
ZuordnungIn(food="Vollmilch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Vollmilch", 500, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.art == "group"
assert zutat.ziel_id == gruppe.id
assert zutat.status == "vorhanden"
def test_wechsel_auf_eine_gruppe_laesst_den_artikel_nicht_stehen(db, nutzer):
"""Sonst stünden beide Ziele in der Zeile und eines gewänne zufällig."""
cat = _kategorie(db, "Kuehlregal")
milch = _artikel(db, "H-Milch", cat, base=BaseUnit.milliliter)
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
setze_zuordnung(ZuordnungIn(food="Milch", art="product", ziel_id=milch.id),
db=db, _=nutzer)
setze_zuordnung(ZuordnungIn(food="Milch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer)
zeile = db.get(MealieFoodMap, "milch")
assert zeile.product_id is None
assert zeile.group_id == gruppe.id
assert db.query(MealieFoodMap).count() == 1
# --------------------------------------------------------------------------
# Widerrufen und Aufräumen
# --------------------------------------------------------------------------
def test_loeschen_laesst_wieder_raten(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Reis", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
loesche_zuordnung(food="Reis", db=db, _=nutzer)
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Reis", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_loeschen_was_es_nicht_gibt_ist_kein_fehler(db, nutzer):
loesche_zuordnung(food="Gibtsnicht", db=db, _=nutzer)
def test_zuordnung_auf_einen_geloeschten_artikel_wird_uebersprungen(db, nutzer):
"""Auf SQLite greift kein Fremdschlüssel – das darf kein Rezept sprengen."""
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Lime Curd", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
db.delete(mehl)
db.commit()
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_unbekanntes_ziel_wird_abgelehnt(db, nutzer):
with pytest.raises(HTTPException) as fehler:
setze_zuordnung(ZuordnungIn(food="Mehl", art="product", ziel_id=999),
db=db, _=nutzer)
assert fehler.value.status_code == 400
assert db.query(MealieFoodMap).count() == 0
def test_zutat_ohne_namen_laesst_sich_nicht_merken(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
with pytest.raises(zuordnung.ZuordnungFehler):
zuordnung.setze(db, " ", "product", mehl.id)
# --------------------------------------------------------------------------
# Liste und Auswahl
# --------------------------------------------------------------------------
def test_liste_nennt_das_ziel_mit_namen(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
setze_zuordnung(ZuordnungIn(food="Flour", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
[eintrag] = list_zuordnungen(db=db, _=nutzer)
assert eintrag.food_name == "Flour"
assert eintrag.food_key == "flour"
assert eintrag.ziel_name == "Weissmehl"
def test_ziele_suchen_ueber_name_und_marke(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl Typ 550", cat, brand="Mühle Nord")
_artikel(db, "Zucker", cat)
namen = [z.name for z in zuordnung_ziele(q="mehl", db=db, _=nutzer)]
assert namen == ["Weissmehl Typ 550"]
nach_marke = [z.name for z in zuordnung_ziele(q="mühle nord", db=db, _=nutzer)]
assert nach_marke == ["Weissmehl Typ 550"]
def test_ziele_nennen_die_basiseinheit(db, nutzer):
"""Damit man vor dem Zuordnen sieht, ob ml oder g geführt wird."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter)
[ziel] = zuordnung_ziele(q="schlagrahm", db=db, _=nutzer)
assert ziel.basis_einheit == "milliliter"
def test_ziele_enthalten_gegenstaende_aber_keine_einzelstuecke(db, nutzer):
"""Raten darf nur Lebensmittel; von Hand zuordnen darf alles Zählbare.
Backpapier ist kein Lebensmittel und steht trotzdem in Rezepten. Ein
Einzelstück dagegen ist ein benanntes Objekt und nie eine Zutat.
"""
sachen = _kategorie(db, "Haushalt", tracking=CategoryTracking.object)
_artikel(db, "Backpapier", sachen, base=BaseUnit.piece)
_artikel(db, "Backpapier-Spender", sachen, base=BaseUnit.piece, individual=True)
namen = [z.name for z in zuordnung_ziele(q="backpapier", db=db, _=nutzer)]
assert namen == ["Backpapier"]
def test_gruppen_stehen_vor_den_artikeln(db, nutzer):
"""Ohne Suchtext zuerst die Gruppen – sie sind die bessere Zuordnung."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Alpenmilch", cat, base=BaseUnit.milliliter)
db.add(Group(name="Trinkmilch"))
db.commit()
arten = [z.art for z in zuordnung_ziele(db=db, _=nutzer)]
assert arten[0] == "group"
# --------------------------------------------------------------------------
# Routenreihenfolge
# --------------------------------------------------------------------------
def test_zuordnungs_routen_stehen_vor_dem_slug():
"""Sonst liest FastAPI „zuordnungen" als Rezept-Slug und fragt Mealie.
Die Reihenfolge der Deklaration entscheidet das – ein Umsortieren der
Datei würde den Fehler sonst still einbauen.
"""
pfade = [r.path for r in router.routes]
slug = pfade.index("/recipes/{slug}")
assert pfade.index("/recipes/zuordnungen") < slug
assert pfade.index("/recipes/zuordnungen/ziele") < slug