Rezepte aus Mealie gegen den Bestand pruefen

Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.

Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.

Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):

1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
   UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
   gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
   sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
   das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
   "nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
   Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
   Kategorie, meldet die Zutat genau das zurueck (Massname und
   Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
   der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.

Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.

Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:

- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
  aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
  Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
  solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
  Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
  statt mit der rohen Schema-Meldung "String should have at least 1
  character", die niemandem sagt, welches Feld gemeint ist.

Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 13:10:04 +02:00
co-authored by Claude Opus 5
parent 7d8a54a3fc
commit 2247c8cf69
12 changed files with 1100 additions and 89 deletions
+2
View File
@@ -24,6 +24,7 @@ from .routers import (
oidc, oidc,
package_types, package_types,
products, products,
recipes,
settings as settings_router, settings as settings_router,
shops, shops,
stock, stock,
@@ -327,6 +328,7 @@ app.include_router(categories.router)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(measures.router) app.include_router(measures.router)
app.include_router(mealie.router) app.include_router(mealie.router)
app.include_router(recipes.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(shops.router) app.include_router(shops.router)
app.include_router(field_definitions.router) app.include_router(field_definitions.router)
+6 -34
View File
@@ -55,45 +55,17 @@ from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values from ..services.fields import FieldError, apply_field_values
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
from ..services.matching import match_score, normalisieren
from ..services.min_stock import schreibe_ueberall from ..services.min_stock import schreibe_ueberall
from ..services.stock import removal_stats from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
# --------------------------------------------------------------------------- # Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen.
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen. # Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie
# --------------------------------------------------------------------------- # genauso braucht.
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) _normalisieren = normalisieren
_match_score = match_score
def _normalisieren(text: str) -> str:
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
text = (text or "").lower().translate(_UMLAUTE)
return re.sub(r"[^a-z0-9]+", " ", text).strip()
def _match_score(zeile: str, name: str) -> int:
"""Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname.
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen.
"""
a, b = _normalisieren(zeile), _normalisieren(name)
if not a or not b:
return 0
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
a_tok, b_tok = a.split(), b.split()
bester = []
for t in a_tok:
m = 0.0
for u in b_tok:
if u.startswith(t) or t.startswith(u):
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
else:
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
bester.append(m)
token = sum(bester) / len(bester) if bester else 0.0
return round(100 * max(gesamt, token))
@router.post("/match", response_model=list[MatchLine]) @router.post("/match", response_model=list[MatchLine])
+129
View File
@@ -0,0 +1,129 @@
"""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 User
from ..schemas import (
RecipeDetail,
RecipePage,
RecipeSummary,
ZutatKandidat,
ZutatOut,
)
from ..services import mealie, rezepte
from ..services.einkaufsliste_text import fehlmengen_text
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)),
)
@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)
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,
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),
)
+58
View File
@@ -728,6 +728,64 @@ class MealieAvailable(BaseModel):
available: bool = False available: bool = False
class RecipeSummary(BaseModel):
"""Ein Rezept in der Liste."""
slug: str
name: str
description: str = ""
image_url: str | None = None
servings: float | None = None
class RecipePage(BaseModel):
items: list[RecipeSummary] = []
page: int = 1
per_page: int = 50
total: int = 0
class ZutatKandidat(BaseModel):
art: str # product | group
id: int
name: str
score: int
class ZutatOut(BaseModel):
"""Eine Zutat, auf den Bestand bezogen."""
text: str
menge: float | None = None
einheit: str = ""
art: str | None = None
ziel_id: int | None = None
ziel_name: str = ""
kandidaten: list[ZutatKandidat] = []
benoetigt_base: float | None = None
bestand_base: float | None = None
fehlt_base: float | None = None
basis_einheit: str = ""
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
status: str = "kein_treffer"
hinweis: str = ""
fehlendes_mass: str = ""
kategorie_id: int | None = None
# Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste.
fehlt_text: str = ""
class RecipeDetail(BaseModel):
slug: str
name: str
description: str = ""
image_url: str | None = None
# Portionen laut Rezept und die, für die gerechnet wurde.
base_servings: float | None = None
servings: float | None = None
skalierung_hinweis: str = ""
kochbar: bool = False
ingredients: list[ZutatOut] = []
# ---- Hausmaße ---- # ---- Hausmaße ----
class MeasureIn(BaseModel): class MeasureIn(BaseModel):
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
@@ -0,0 +1,37 @@
"""Fehlmenge als lesbare Angabe – „2 Packungen (1 l)" statt „1000 ml".
Eigene kleine Datei, damit der Rezeptabgleich dieselbe Sprache spricht wie die
Einkaufsliste: Dort wird eine Fehlmenge ebenfalls als zählbares Gebinde
ausgegeben und aufgerundet, weil man nur ganze Packungen kauft.
"""
from __future__ import annotations
import math
from sqlalchemy.orm import Session
from ..models import Product
from .conversion import BASE_LABEL, BaseUnit, article_unit
def fehlmengen_text(db: Session, pruefung) -> str:
"""„2 Packungen" bzw. „300 g" – leer, wenn nichts fehlt."""
fehlt = getattr(pruefung, "fehlt_base", None)
if not fehlt or fehlt <= 1e-9:
return ""
if pruefung.art == "product" and pruefung.ziel_id is not None:
produkt = db.get(Product, pruefung.ziel_id)
if produkt is not None:
faktor, label = article_unit(produkt)
if produkt.package_size and produkt.package_size > 0:
# Ganze Gebinde: man kauft keine halbe Packung.
anzahl = math.ceil(fehlt / (faktor or 1.0) - 1e-9)
return f"{anzahl:g} {label}" + ("" if anzahl == 1 else "en" if label.endswith("ung") else "")
return f"{fehlt:g} {label}"
basis = pruefung.basis_einheit
try:
name = BASE_LABEL[BaseUnit(basis)]
except (ValueError, KeyError):
name = basis
return f"{fehlt:g} {name}"
+45
View File
@@ -0,0 +1,45 @@
"""Unscharfer Namensabgleich – aus dem Kassenzettel-Scan, jetzt auch für Rezepte.
Lag bis dahin in ``routers/products.py``. Hierher gezogen, weil der
Rezeptabgleich dieselbe Frage stellt: Welcher Artikel ist mit „Mehl" oder
„MÜHLEN SCHNITZ" gemeint? Zwei Kopien derselben Heuristik würden mit der Zeit
auseinanderlaufen.
"""
from __future__ import annotations
import difflib
import re
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def normalisieren(text: str) -> str:
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
text = (text or "").lower().translate(_UMLAUTE)
return re.sub(r"[^a-z0-9]+", " ", text).strip()
def match_score(zeile: str, name: str) -> int:
"""Ähnlichkeit 0–100 zwischen gesuchtem Text und einem Namen.
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen –
und damit eine Rezeptzutat „Mehl" den „Weissmehl Typ 550" findet.
"""
a, b = normalisieren(zeile), normalisieren(name)
if not a or not b:
return 0
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
a_tok, b_tok = a.split(), b.split()
bester = []
for t in a_tok:
m = 0.0
for u in b_tok:
if u.startswith(t) or t.startswith(u):
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
else:
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
bester.append(m)
token = sum(bester) / len(bester) if bester else 0.0
return round(100 * max(gesamt, token))
+268
View File
@@ -0,0 +1,268 @@
"""Rezeptzutaten gegen den Bestand prüfen: Kann ich das kochen?
Bewusst **ohne HTTP** – die Zutaten kommen als Wörterbücher herein, so wie
Mealie sie liefert. Dadurch lässt sich der ganze Abgleich mit handgeschriebenen
Zutaten prüfen, ohne eine Mealie-Instanz zu brauchen; das Netz steckt allein in
``services/mealie.py``.
Je Zutat beantwortet das hier drei Fragen nacheinander:
1. **Welcher Artikel ist gemeint?** Erst eine gemerkte Zuordnung, sonst unscharf
über Namen – gegen Gruppen und Artikel, denn „Milch" ist markenübergreifend
gemeint und das ist genau, wofür es Gruppen gibt.
2. **Wie viel ist das in Gramm/Milliliter/Stück?** Über die Einheiten und die
Hausmaße der Kategorie („2 EL Mehl").
3. **Reicht der Bestand?**
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from ..models import Group, Product
from .conversion import ConversionError, article_unit, group_min_context, to_base
from .matching import match_score, normalisieren
from .stock import current_stock, summe_bestand_gewichtet
# Ab hier gilt ein Name als Treffer. Bewusst strenger als beim Kassenzettel:
# Dort sieht ein Mensch die Vorschläge durch, hier soll nicht stillschweigend
# das Falsche als „vorhanden" gelten.
SCHWELLE = 60
@dataclass
class Kandidat:
art: str # "product" | "group"
id: int
name: str
score: int
@dataclass
class ZutatPruefung:
"""Eine Zutat des Rezepts, auf den Bestand bezogen."""
text: str # wie das Rezept es schreibt
menge: float | None = None # skalierte Menge in der Rezept-Einheit
einheit: str = ""
# Zuordnung
art: str | None = None # "product" | "group"
ziel_id: int | None = None
ziel_name: str = ""
kandidaten: list[Kandidat] = field(default_factory=list)
# Mengen in Basiseinheiten
benoetigt_base: float | None = None
bestand_base: float | None = None
fehlt_base: float | None = None
basis_einheit: str = ""
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
status: str = "kein_treffer"
hinweis: str = ""
# Für „Maß jetzt anlegen": welches Maß fehlt und für welche Kategorie.
fehlendes_mass: str = ""
kategorie_id: int | None = None
def _zutat_text(zutat: dict) -> str:
"""Wie das Rezept die Zutat schreibt – für die Anzeige."""
for schluessel in ("display", "originalText", "original_text", "note"):
wert = zutat.get(schluessel)
if wert:
return str(wert)
essen = zutat.get("food") or {}
return str(essen.get("name") or "Zutat")
def _lebensmittel(zutat: dict) -> str:
essen = zutat.get("food") or {}
name = essen.get("name") or ""
if name:
return str(name)
# Ohne verknüpftes Lebensmittel bleibt nur der freie Text („2 Eier").
return str(zutat.get("note") or zutat.get("display") or "")
def _einheit(zutat: dict) -> str:
einheit = zutat.get("unit") or {}
if not isinstance(einheit, dict):
return ""
return str(einheit.get("abbreviation") or einheit.get("name") or "")
def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, str]:
"""Skalierungsfaktor und, falls nötig, ein Hinweis warum nicht skaliert wird."""
if not portionen or portionen <= 0:
return 1.0, ""
basis = rezept.get("recipeServings") or rezept.get("recipe_servings") or 0
if not basis:
basis = rezept.get("recipeYieldQuantity") or rezept.get("recipe_yield_quantity") or 0
try:
basis = float(basis)
except (TypeError, ValueError):
basis = 0.0
if basis <= 0:
return 1.0, (
"Das Rezept nennt keine Portionszahl – die Mengen gelten deshalb "
"unverändert."
)
return portionen / basis, ""
def _kandidaten(db: Session, name: str) -> list[Kandidat]:
"""Artikel und Gruppen nach Namensähnlichkeit, bester zuerst."""
if not normalisieren(name):
return []
treffer: list[Kandidat] = []
for gruppe in db.query(Group).all():
treffer.append(Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name)))
for produkt in db.query(Product).all():
voll = f"{produkt.name} {produkt.brand or ''}".strip()
treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll)))
treffer.sort(key=lambda k: (-k.score, k.name))
return [k for k in treffer if k.score > 0][:5]
def _bestand(db: Session, art: str, ziel_id: int) -> tuple[float, str]:
"""Bestand in Basiseinheiten plus die Bezeichnung der Basiseinheit."""
if art == "product":
produkt = db.get(Product, ziel_id)
if produkt is None:
return 0.0, ""
return current_stock(db, produkt.id), produkt.base_unit.value
gruppe = db.get(Group, ziel_id)
if gruppe is None:
return 0.0, ""
ctx = group_min_context(gruppe)
menge = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
return menge, (ctx.base_unit.value if ctx.base_unit else "")
def _referenzartikel(db: Session, art: str, ziel_id: int) -> Product | None:
"""Der Artikel, dessen Kategorie die Hausmaße liefert.
Bei einer Gruppe der erste zugehörige Artikel – „1 Tasse Milch" hängt an der
Kategorie der Milch, nicht an der Gruppe selbst.
"""
if art == "product":
return db.get(Product, ziel_id)
gruppe = db.get(Group, ziel_id)
if gruppe is None:
return None
ctx = group_min_context(gruppe)
return ctx.matching[0] if ctx.matching else None
def pruefe_zutaten(
db: Session,
zutaten: list[dict],
*,
faktor: float = 1.0,
zuordnung: dict[str, tuple[str, int]] | None = None,
) -> list[ZutatPruefung]:
"""Alle Zutaten eines Rezepts prüfen.
``zuordnung`` sind gemerkte Entscheidungen („Mealie-Lebensmittel X ist bei
uns Gruppe Y"), Schlüssel ist der normalisierte Name.
"""
zuordnung = zuordnung or {}
ergebnis: list[ZutatPruefung] = []
for zutat in zutaten:
pruefung = ZutatPruefung(text=_zutat_text(zutat))
name = _lebensmittel(zutat)
pruefung.einheit = _einheit(zutat)
rohmenge = zutat.get("quantity")
if zutat.get("disableAmount") or zutat.get("disable_amount") or not rohmenge:
pruefung.menge = None
else:
try:
pruefung.menge = float(rohmenge) * faktor
except (TypeError, ValueError):
pruefung.menge = None
# 1. Zuordnen
gemerkt = zuordnung.get(normalisieren(name))
if gemerkt is not None:
pruefung.art, pruefung.ziel_id = gemerkt
else:
pruefung.kandidaten = _kandidaten(db, name)
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
if bester is not None and bester.score >= SCHWELLE:
pruefung.art, pruefung.ziel_id = bester.art, bester.id
if pruefung.ziel_id is None:
pruefung.status = "kein_treffer"
pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet."
ergebnis.append(pruefung)
continue
referenz = _referenzartikel(db, pruefung.art or "product", pruefung.ziel_id)
pruefung.ziel_name = (
referenz.name if pruefung.art == "product" and referenz is not None
else (db.get(Group, pruefung.ziel_id).name if pruefung.art == "group" else "")
)
if referenz is not None:
pruefung.kategorie_id = referenz.category_id
bestand, basis = _bestand(db, pruefung.art or "product", pruefung.ziel_id)
pruefung.bestand_base = bestand
pruefung.basis_einheit = basis
# 2. Menge umrechnen
if pruefung.menge is None:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = "Das Rezept nennt keine Menge."
elif referenz is None:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = "Zu dieser Gruppe gibt es noch keinen Artikel."
else:
try:
pruefung.benoetigt_base = to_base(
db, referenz, pruefung.menge,
pruefung.einheit or referenz.base_unit.value,
hausmasse=True,
)
except ConversionError as fehler:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = str(fehler)
if "kein Maß" in str(fehler):
pruefung.fehlendes_mass = pruefung.einheit
ergebnis.append(pruefung)
continue
# 3. Vergleichen
if pruefung.benoetigt_base is not None:
fehlt = pruefung.benoetigt_base - (pruefung.bestand_base or 0.0)
pruefung.fehlt_base = max(0.0, fehlt)
if fehlt <= 1e-9:
pruefung.status = "vorhanden"
elif (pruefung.bestand_base or 0.0) > 1e-9:
pruefung.status = "zu_wenig"
else:
pruefung.status = "fehlt"
elif pruefung.status == "menge_unbekannt" and (pruefung.bestand_base or 0.0) <= 1e-9:
# Ohne Menge lässt sich nur sagen, ob überhaupt etwas da ist.
pruefung.status = "fehlt"
pruefung.hinweis = (pruefung.hinweis + " Vorrätig ist davon nichts.").strip()
ergebnis.append(pruefung)
return ergebnis
def kochbar(pruefungen: list[ZutatPruefung]) -> bool:
"""Reicht alles? Unklare Mengen zählen bewusst als „nicht sicher"."""
return all(p.status == "vorhanden" for p in pruefungen)
def artikeleinheiten(db: Session, pruefung: ZutatPruefung) -> tuple[float, str]:
"""Fehlmenge als zählbares Gebinde („2 Packungen") statt in Gramm."""
if pruefung.fehlt_base is None or pruefung.art != "product" or pruefung.ziel_id is None:
return 0.0, ""
produkt = db.get(Product, pruefung.ziel_id)
if produkt is None:
return 0.0, ""
faktor, label = article_unit(produkt)
return (pruefung.fehlt_base / (faktor or 1.0)), label
+255
View File
@@ -0,0 +1,255 @@
"""Rezeptzutaten gegen den Bestand: Kann ich das kochen?
Ohne Mealie – die Zutaten stehen hier so im Test, wie Mealie sie liefern würde.
"""
import pytest
from app.models import (
BaseUnit,
Category,
CategoryMeasure,
CategoryTracking,
Group,
Measure,
Product,
)
from app.services.rezepte import faktor_fuer_portionen, kochbar, pruefe_zutaten
from app.services.stock import check_in
def _zutat(name, menge=None, einheit=None, text=None):
"""Eine Zutat im Format von Mealie."""
return {
"quantity": menge,
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
"food": {"name": name},
"display": text or f"{menge or ''} {einheit or ''} {name}".strip(),
}
def _kategorie(db, name):
cat = Category(name=name, tracking=CategoryTracking.food.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0):
product = Product(name=name, base_unit=base, package_size=package_size,
category_id=cat.id, source="manual")
db.add(product)
db.commit()
db.refresh(product)
if bestand:
check_in(db, product, bestand, base.value, None, None, None)
db.commit()
return product
# --------------------------------------------------------------------------
# Portionen
# --------------------------------------------------------------------------
def test_portionen_skalieren_die_mengen(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=5000)
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 4}, 6)
assert faktor == 1.5
assert hinweis == ""
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")], faktor=faktor)
assert zutat.menge == 300
assert zutat.benoetigt_base == 300
def test_ohne_portionsangabe_wird_nicht_skaliert(db):
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 0}, 6)
assert faktor == 1.0
assert "keine Portionszahl" in hinweis
def test_ohne_wunsch_bleibt_es_wie_im_rezept(db):
assert faktor_fuer_portionen({"recipeServings": 4}, None) == (1.0, "")
# --------------------------------------------------------------------------
# Reicht der Bestand?
# --------------------------------------------------------------------------
def test_genug_vorhanden(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
assert zutat.status == "vorhanden"
assert zutat.fehlt_base == 0
assert kochbar([zutat]) is True
def test_zu_wenig_meldet_die_fehlmenge(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=150)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
assert zutat.status == "zu_wenig"
assert zutat.fehlt_base == 350
assert kochbar([zutat]) is False
def test_gar_nichts_da(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=0)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
assert zutat.status == "fehlt"
assert zutat.fehlt_base == 500
def test_unbekannte_zutat_wird_nicht_still_als_vorhanden_gewertet(db):
_kategorie(db, "Trockenwaren")
[zutat] = pruefe_zutaten(db, [_zutat("Safranfäden", 1, "g")])
assert zutat.status == "kein_treffer"
assert kochbar([zutat]) is False
def test_zutat_ohne_menge_ist_unklar(db):
cat = _kategorie(db, "Gewürze")
_artikel(db, "Salz", cat, bestand=500)
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None, text="Salz nach Geschmack")])
assert zutat.status == "menge_unbekannt"
assert kochbar([zutat]) is False
def test_ohne_menge_und_ohne_bestand_fehlt_es(db):
cat = _kategorie(db, "Gewürze")
_artikel(db, "Salz", cat, bestand=0)
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None)])
assert zutat.status == "fehlt"
# --------------------------------------------------------------------------
# Zuordnung
# --------------------------------------------------------------------------
def test_aehnlicher_name_wird_gefunden(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl Typ 550", cat, bestand=2000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
assert zutat.art == "product"
assert zutat.ziel_name == "Weissmehl Typ 550"
def test_gruppe_zaehlt_marken_zusammen(db):
"""„Milch" ist markenuebergreifend gemeint – dafuer gibt es Gruppen."""
gruppe = Group(name="Milch")
db.add(gruppe)
db.commit()
cat = _kategorie(db, "Molkerei")
a = _artikel(db, "Valflora Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
b = _artikel(db, "Emmi Vollmilch", cat, base=BaseUnit.milliliter, bestand=500)
a.group_id = gruppe.id
b.group_id = gruppe.id
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Milch", 1200, "ml")])
assert zutat.art == "group"
assert zutat.bestand_base == 1500 # beide Marken zusammen
assert zutat.status == "vorhanden"
def test_gemerkte_zuordnung_schlaegt_die_suche(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
richtig = _artikel(db, "Dinkelmehl", cat, bestand=2000)
[zutat] = pruefe_zutaten(
db, [_zutat("Mehl", 100, "g")],
zuordnung={"mehl": ("product", richtig.id)},
)
assert zutat.ziel_id == richtig.id
assert zutat.ziel_name == "Dinkelmehl"
def test_schwacher_treffer_liefert_kandidaten_statt_einer_annahme(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Kurkuma", 1, "g")])
assert zutat.status == "kein_treffer"
assert zutat.ziel_id is None
# Vorschlaege gibt es trotzdem – daraus baut die Oberflaeche die Korrektur.
assert zutat.kandidaten
# --------------------------------------------------------------------------
# Hausmaße
# --------------------------------------------------------------------------
def test_essloeffel_wird_umgerechnet(db):
cat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
el = db.query(Measure).filter(Measure.name == "Esslöffel").one()
db.add(CategoryMeasure(measure_id=el.id, category_id=cat.id, amount=10,
base=BaseUnit.gram.value))
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 2, "EL")])
assert zutat.benoetigt_base == 20
assert zutat.status == "vorhanden"
def test_fehlendes_mass_wird_zum_anlegen_gemeldet(db):
"""Daraus baut die Oberflaeche „Maß jetzt anlegen"."""
cat = _kategorie(db, "Hülsenfrüchte")
_artikel(db, "Berglinsen", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
assert zutat.status == "menge_unbekannt"
assert zutat.fehlendes_mass == "Tasse"
assert zutat.kategorie_id == cat.id
assert "kein Maß" in zutat.hinweis
def test_unbekannte_einheit_wird_benannt(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 1, "furlong")])
assert zutat.status == "menge_unbekannt"
assert "furlong" in zutat.hinweis
# --------------------------------------------------------------------------
# Ganzes Rezept
# --------------------------------------------------------------------------
def test_ein_rezept_mit_allem(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
_artikel(db, "Zucker", cat, bestand=50)
zutaten = [
_zutat("Weissmehl", 200, "g"), # reicht
_zutat("Zucker", 100, "g"), # zu wenig
_zutat("Safran", 1, "g"), # kennt niemand
]
ergebnis = pruefe_zutaten(db, zutaten)
assert [z.status for z in ergebnis] == ["vorhanden", "zu_wenig", "kein_treffer"]
assert kochbar(ergebnis) is False
+10
View File
@@ -26,6 +26,7 @@ import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units"; import Units from "./pages/Units";
import Users from "./pages/Users"; import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList"; import ShoppingList from "./pages/ShoppingList";
import Rezepte from "./pages/Rezepte";
import History from "./pages/History"; import History from "./pages/History";
import Verliehen from "./pages/Verliehen"; import Verliehen from "./pages/Verliehen";
import Transfer from "./pages/Transfer"; import Transfer from "./pages/Transfer";
@@ -59,6 +60,9 @@ function Sidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [dashboards, setDashboards] = useState([]); const [dashboards, setDashboards] = useState([]);
// Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler
// hier darf die Leiste nicht stoeren, deshalb still abfangen.
const [mealie, setMealie] = useState(false);
// Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die // Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die
// Spalteneinstellungen der Tabellen. // Spalteneinstellungen der Tabellen.
const [listenOffen, setListenOffen] = useState( const [listenOffen, setListenOffen] = useState(
@@ -88,6 +92,10 @@ function Sidebar() {
return () => window.removeEventListener("dashboards-geaendert", laden); return () => window.removeEventListener("dashboards-geaendert", laden);
}, []); }, []);
useEffect(() => {
api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {});
}, []);
function handleLogout() { function handleLogout() {
logout(); logout();
navigate("/login"); navigate("/login");
@@ -132,6 +140,7 @@ function Sidebar() {
<span className="label">{e.label}</span> <span className="label">{e.label}</span>
</NavLink> </NavLink>
))} ))}
{mealie && <NavItem to="/rezepte" icon="food" label="Rezepte" />}
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" /> <NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" /> <NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
<NavItem to="/verliehen" icon="loan" label="Verliehen" /> <NavItem to="/verliehen" icon="loan" label="Verliehen" />
@@ -237,6 +246,7 @@ export default function App() {
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} /> <Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} /> <Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} /> <Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} /> <Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
und Auslagern, keine Stammdatenänderung. */} und Auslagern, keine Stammdatenänderung. */}
+8
View File
@@ -451,6 +451,14 @@ export const api = {
mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }), mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
mealieToken: (body) => request("/mealie/token", { method: "PUT", body }), mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
mealieTrennen: () => request("/mealie/config", { method: "DELETE" }), mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
// Rezepte aus Mealie, gegen den Bestand geprueft.
recipes: ({ q, page = 1, perPage = 50 } = {}) => {
const s = new URLSearchParams({ page: String(page), per_page: String(perPage) });
if (q) s.set("q", q);
return request(`/recipes?${s.toString()}`);
},
recipe: (slug, servings) =>
request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`),
listSettings: () => request("/settings"), listSettings: () => request("/settings"),
setSetting: (key, value) => setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt } from "../units";
/**
* Rezepte aus Mealie – mit der einen Frage, die zählt: Kann ich das heute
* kochen, oder muss ich vorher einkaufen?
*
* Links die Liste, rechts das gewählte Rezept mit allen Zutaten und ihrem
* Zustand. Die Portionenzahl lässt sich hochdrehen; der Server rechnet die
* Mengen dann entsprechend hoch, bevor er sie mit dem Bestand vergleicht.
*/
// Die Basiseinheit kommt als technischer Wert („gram"); in der Tabelle soll
// stehen, was auf der Packung steht.
const BASIS_KURZ = { gram: "g", milliliter: "ml", piece: "Stk" };
const kurz = (basis) => BASIS_KURZ[basis] || basis || "";
const ZUSTAND = {
vorhanden: { text: "vorhanden", klasse: "ok", icon: "check" },
zu_wenig: { text: "zu wenig", klasse: "warn", icon: "alert" },
fehlt: { text: "fehlt", klasse: "danger", icon: "alert" },
menge_unbekannt: { text: "Menge unklar", klasse: "muted", icon: "alert" },
kein_treffer: { text: "nicht zugeordnet", klasse: "muted", icon: "search" },
};
function Zustand({ status }) {
const z = ZUSTAND[status] || ZUSTAND.kein_treffer;
return <span className={`badge ${z.klasse}`}>{z.text}</span>;
}
export default function Rezepte() {
const [liste, setListe] = useState([]);
const [suche, setSuche] = useState("");
const [slug, setSlug] = useState(null);
const [rezept, setRezept] = useState(null);
const [portionen, setPortionen] = useState("");
const [fehler, setFehler] = useState(null);
const [laedt, setLaedt] = useState(true);
async function ladeListe(q = "") {
setLaedt(true);
try {
const daten = await api.recipes({ q: q || undefined });
setListe(daten.items || []);
setFehler(null);
} catch (err) { setFehler(err.message); } finally { setLaedt(false); }
}
useEffect(() => { ladeListe(); }, []);
async function oeffne(s, wunschPortionen) {
setSlug(s);
setRezept(null);
try {
const daten = await api.recipe(s, wunschPortionen);
setRezept(daten);
// Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen.
if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : "");
setFehler(null);
} catch (err) { setFehler(err.message); }
}
function setzePortionen(wert) {
setPortionen(wert);
const zahl = Number(String(wert).replace(",", "."));
if (slug && Number.isFinite(zahl) && zahl > 0) oeffne(slug, zahl);
}
const fehlende = (rezept?.ingredients || []).filter(
(z) => z.status === "fehlt" || z.status === "zu_wenig",
);
return (
<div>
<div className="page-head">
<div>
<h1>Rezepte</h1>
<div className="sub">Aus Mealie – geprüft gegen deinen Bestand</div>
</div>
</div>
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<form className="field-inline" style={{ marginBottom: 0 }}
onSubmit={(e) => { e.preventDefault(); ladeListe(suche); }}>
<input value={suche} placeholder="Rezept suchen…"
onChange={(e) => setSuche(e.target.value)} />
<button className="btn"><Icon name="search" size={16} />Suchen</button>
</form>
</div>
<div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "minmax(240px, 320px) 1fr" }}>
<div className="card">
<div className="card-head"><Icon name="food" /><h2>{liste.length} Rezepte</h2></div>
{laedt && <p className="muted small">Wird geladen…</p>}
{!laedt && liste.length === 0 && (
<p className="muted small">Mealie liefert keine Rezepte.</p>
)}
<ul className="clean-list">
{liste.map((r) => (
<li key={r.slug}>
<button type="button"
className={`btn sm ghost${r.slug === slug ? " primary" : ""}`}
style={{ width: "100%", justifyContent: "flex-start" }}
onClick={() => oeffne(r.slug, null)}>
{r.name}
</button>
</li>
))}
</ul>
</div>
<div className="card">
{!rezept && <p className="muted">Links ein Rezept wählen.</p>}
{rezept && (
<>
<div className="card-head">
<Icon name={rezept.kochbar ? "check" : "cart"} />
<h2>{rezept.name}</h2>
<span className={`badge ${rezept.kochbar ? "ok" : "warn"}`}
style={{ marginLeft: "auto" }}>
{rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`}
</span>
</div>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<label style={{ margin: 0, maxWidth: 160 }}>
Portionen
<input type="number" min="0.5" step="0.5" value={portionen}
onChange={(e) => setzePortionen(e.target.value)} />
</label>
{rezept.base_servings ? (
<span className="muted small">
Rezept ist für {fmt(rezept.base_servings)} gedacht.
</span>
) : null}
</div>
{rezept.skalierung_hinweis && (
<p className="muted small mt-0">{rezept.skalierung_hinweis}</p>
)}
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Zutat</th><th>Gebraucht</th><th>Im Haus</th><th>Zustand</th></tr>
</thead>
<tbody>
{rezept.ingredients.map((z, i) => (
<tr key={i} className={
z.status === "fehlt" ? "row-danger"
: z.status === "zu_wenig" ? "row-warn" : ""}>
<td data-label="Zutat">
<span className="strong">{z.text}</span>
{z.ziel_name && (
<div className="muted small">
{z.art === "group" ? "Gruppe " : ""}{z.ziel_name}
</div>
)}
{z.hinweis && <div className="muted small">{z.hinweis}</div>}
</td>
<td data-label="Gebraucht">
{z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"}
{z.benoetigt_base != null && (
<div className="muted small">
{fmt(z.benoetigt_base)} {kurz(z.basis_einheit)}
</div>
)}
</td>
<td data-label="Im Haus">
{z.bestand_base != null
? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"}
{z.fehlt_text && (
<div className="muted small">fehlt: {z.fehlt_text}</div>
)}
</td>
<td data-label="Zustand"><Zustand status={z.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
{!rezept.kochbar && (
<p className="muted small">
Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste"
kommt, sobald die Einkaufsliste eigene Einträge speichern kann.
</p>
)}
</>
)}
</div>
</div>
</div>
);
}
+84 -55
View File
@@ -198,6 +198,10 @@ function MealieCard() {
const [mitToken, setMitToken] = useState(false); const [mitToken, setMitToken] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState(null); const [fehler, setFehler] = useState(null);
// Das Formular steht nur offen, solange nichts eingerichtet ist – oder wenn
// man es ausdruecklich wieder aufmacht. Ein Dauerzustand gehoert nicht in ein
// Eingabeformular, sonst sieht man nie, ob es geklappt hat.
const [aendern, setAendern] = useState(false);
async function laden(pruefen = false) { async function laden(pruefen = false) {
try { try {
@@ -208,8 +212,17 @@ function MealieCard() {
} }
useEffect(() => { laden(); }, []); useEffect(() => { laden(); }, []);
const verbunden = !!status?.configured;
async function verbinden(e) { async function verbinden(e) {
e.preventDefault(); e.preventDefault();
// Eigene Pruefung vor dem Absenden: Der Server antwortet sonst mit der
// rohen Schema-Meldung („String should have at least 1 character"), und
// die erklaert niemandem, welches Feld gemeint ist.
if (!url.trim()) { setFehler("Bitte die Adresse von Mealie eintragen."); return; }
if (mitToken && !token.trim()) { setFehler("Bitte das Token aus Mealie einfügen."); return; }
if (!mitToken && !benutzer.trim()) { setFehler("Bitte den Benutzernamen in Mealie eintragen."); return; }
if (!mitToken && !passwort) { setFehler("Bitte das Passwort eingeben."); return; }
setBusy(true); setBusy(true);
setFehler(null); setFehler(null);
try { try {
@@ -219,6 +232,7 @@ function MealieCard() {
setStatus(s); setStatus(s);
setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert
setToken(""); setToken("");
setAendern(false); // Formular zu, der Zustand oben sagt jetzt alles
toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht."); toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht.");
} catch (err) { setFehler(err.message); } finally { setBusy(false); } } catch (err) { setFehler(err.message); } finally { setBusy(false); }
} }
@@ -235,6 +249,7 @@ function MealieCard() {
await api.mealieTrennen(); await api.mealieTrennen();
setStatus({ configured: false, base_url: "", token_set: false }); setStatus({ configured: false, base_url: "", token_set: false });
setUrl(""); setBenutzer(""); setPasswort(""); setToken(""); setUrl(""); setBenutzer(""); setPasswort(""); setToken("");
setAendern(false);
toast("Verbindung gelöst."); toast("Verbindung gelöst.");
} catch (err) { setFehler(err.message); } } catch (err) { setFehler(err.message); }
} }
@@ -249,68 +264,82 @@ function MealieCard() {
Dauertoken ausstellen und vergisst es. Dauertoken ausstellen und vergisst es.
</p> </p>
{status?.configured && (
<div className={`alert ${status.reachable === false ? "error" : "ok"}`}>
<Icon name={status.reachable === false ? "alert" : "check"} size={16} />
<span>
{status.base_url}
{status.version ? ` · Mealie ${status.version}` : ""}
{" · "}
{status.reachable === false
? (status.error || "antwortet nicht")
: status.reachable === true ? "erreichbar" : "Token gesetzt"}
</span>
</div>
)}
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>} {fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<label> {verbunden && !aendern ? (
Adresse <>
<input value={url} placeholder="https://mealie.example.com" <div className={`alert ${status.reachable === false ? "error" : "ok"}`}>
onChange={(e) => setUrl(e.target.value)} /> <Icon name={status.reachable === false ? "alert" : "check"} size={16} />
</label> <span>
{status.reachable === false
{!mitToken ? ( ? `Nicht erreichbar: ${status.error || "unbekannter Grund"}`
<div className="row"> : "Verbunden"}
<label className="grow"> </span>
Benutzername in Mealie </div>
<input value={benutzer} onChange={(e) => setBenutzer(e.target.value)} /> <dl className="kv">
</label> <dt>Adresse</dt><dd>{status.base_url}</dd>
<label className="grow"> {status.version && (<><dt>Mealie-Version</dt><dd>{status.version}</dd></>)}
Passwort <dt>Zugang</dt>
<input type="password" value={passwort} autoComplete="new-password" <dd>Dauertoken hinterlegt – dein Passwort ist nicht gespeichert.</dd>
onChange={(e) => setPasswort(e.target.value)} /> </dl>
</label> <div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
</div>
) : (
<label>
Dauertoken aus Mealie
<input value={token} onChange={(e) => setToken(e.target.value)}
placeholder="in Mealie unter Profil ▸ API-Tokens erzeugen" />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={mitToken}
onChange={(e) => setMitToken(e.target.checked)} />
Ich gebe lieber selbst ein Token ein
</label>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
<button className="btn primary" disabled={busy || !url}>
<Icon name="check" size={16} />{busy ? "Verbinde…" : "Verbinden"}
</button>
{status?.configured && (
<>
<button type="button" className="btn" onClick={() => laden(true)}> <button type="button" className="btn" onClick={() => laden(true)}>
Verbindung prüfen <Icon name="check" size={16} />Verbindung prüfen
</button>
<button type="button" className="btn" onClick={() => setAendern(true)}>
<Icon name="edit" size={16} />Zugang ändern
</button> </button>
<button type="button" className="btn danger" onClick={trennen}> <button type="button" className="btn danger" onClick={trennen}>
Trennen Trennen
</button> </button>
</> </div>
)} </>
</div> ) : (
<>
<label>
Adresse
<input value={url} placeholder="https://mealie.example.com"
onChange={(e) => setUrl(e.target.value)} />
</label>
{!mitToken ? (
<div className="row">
<label className="grow">
Benutzername in Mealie
<input value={benutzer} onChange={(e) => setBenutzer(e.target.value)} />
</label>
<label className="grow">
Passwort
<input type="password" value={passwort} autoComplete="new-password"
onChange={(e) => setPasswort(e.target.value)} />
</label>
</div>
) : (
<label>
Dauertoken aus Mealie
<input value={token} onChange={(e) => setToken(e.target.value)}
placeholder="in Mealie unter Profil ▸ API-Tokens erzeugen" />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={mitToken}
onChange={(e) => setMitToken(e.target.checked)} />
Ich gebe lieber selbst ein Token ein
</label>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
<button className="btn primary" disabled={busy}>
<Icon name="check" size={16} />{busy ? "Verbinde…" : "Verbinden"}
</button>
{verbunden && (
<button type="button" className="btn" onClick={() => { setAendern(false); setFehler(null); }}>
Abbrechen
</button>
)}
</div>
</>
)}
</form> </form>
); );
} }