Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept

Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.

Zwei Tabellen dafuer, und die Trennung ist der Kern:

shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.

shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.

Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.

Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.

In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.

Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 14:30:00 +02:00
co-authored by Claude Opus 5
parent f02e9a3840
commit 795e233a1b
11 changed files with 1121 additions and 21 deletions
+4
View File
@@ -26,6 +26,7 @@ from .routers import (
products,
recipes,
settings as settings_router,
shopping,
shops,
stock,
transfer,
@@ -320,6 +321,9 @@ app.include_router(groups.router)
app.include_router(units.router)
app.include_router(package_types.router)
app.include_router(views.router)
# Nach views.router: Die gerechnete Liste steht dort, die gespeicherten
# Eintraege und Haken hier - gleicher Pfadanfang /shopping-list.
app.include_router(shopping.router)
app.include_router(transfer.router)
app.include_router(api_tokens.router)
app.include_router(settings_router.router)
+85
View File
@@ -815,6 +815,91 @@ class IntegrationSecret(Base):
)
class ShoppingEntry(Base):
"""Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat.
Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter
ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt
die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer
"Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese
Zutaten fehlen mir fuer das Rezept".
Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt.
Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der
Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten
unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen
kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier
keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in
der sie auf den Zettel kommt.
``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei
einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl.
``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als
Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man
ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen
Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die
Menge neu, statt die Liste zu verdoppeln.
"""
__tablename__ = "shopping_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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
)
free_text: Mapped[str | None] = mapped_column(String(120), nullable=True)
quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True)
# BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product
# .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist.
base: Mapped[str | None] = mapped_column(String(16), nullable=True)
# "manual" | "recipe"
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, nullable=False
)
product: Mapped[Product | None] = relationship()
group: Mapped[Group | None] = relationship()
class ShoppingCheck(Base):
"""Ein abgehakter Posten der GERECHNETEN Einkaufsliste.
Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie
entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich
deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im
Supermarkt.
Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst:
``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem
Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit
bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der
Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das
Aufraeumen erledigt "Erledigte entfernen".
"""
__tablename__ = "shopping_checks"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
checked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, nullable=False
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class MealieFoodMap(Base):
"""Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y.
+40 -1
View File
@@ -17,15 +17,17 @@ from ..schemas import (
RecipeDetail,
RecipePage,
RecipeSummary,
ShoppingFromRecipe,
ZielVorschlag,
ZuordnungIn,
ZuordnungOut,
ZutatKandidat,
ZutatOut,
)
from ..services import mealie, rezepte, zuordnung
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"])
@@ -229,6 +231,43 @@ def get_recipe(
)
@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,
+150
View File
@@ -0,0 +1,150 @@
"""Die gespeicherte Seite der Einkaufsliste: eigene Einträge und Haken.
Die gerechnete Liste steht in ``routers/views.py`` und bleibt dort. Dieser
Router verwaltet nur, was jemand selbst draufgesetzt hat — und das Abhaken, das
bisher im Browser verfiel und nach dem Neuladen mitten im Supermarkt weg war.
"""
# 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 ShoppingEntry, User
from ..schemas import ShoppingEntryIn, ShoppingEntryOut, ShoppingEntryPatch
from ..services import einkaufsliste
router = APIRouter(prefix="/shopping-list", tags=["shopping"])
def eintrag_out(db: Session, eintrag: ShoppingEntry) -> ShoppingEntryOut:
art = (
"product" if eintrag.product_id is not None
else "group" if eintrag.group_id is not None
else "text"
)
return ShoppingEntryOut(
id=eintrag.id, art=art,
product_id=eintrag.product_id, group_id=eintrag.group_id,
free_text=eintrag.free_text,
name=einkaufsliste.bezeichnung(db, eintrag),
quantity_base=eintrag.quantity_base, base=eintrag.base,
menge_text=einkaufsliste.mengen_text(db, eintrag),
source=eintrag.source, recipe_slug=eintrag.recipe_slug,
recipe_name=eintrag.recipe_name, done=eintrag.done,
created_at=eintrag.created_at,
)
@router.get("/entries", response_model=list[ShoppingEntryOut])
def list_entries(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ShoppingEntryOut]:
"""Die eigenen Einträge, offene zuerst."""
return [eintrag_out(db, e) for e in einkaufsliste.eintraege(db)]
@router.post("/entries", response_model=ShoppingEntryOut,
status_code=status.HTTP_201_CREATED)
def create_entry(
payload: ShoppingEntryIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ShoppingEntryOut:
"""Etwas auf die Liste setzen – Artikel, Gruppe oder einfach Text."""
try:
eintrag = einkaufsliste.anlegen(
db, product_id=payload.product_id, group_id=payload.group_id,
free_text=payload.free_text, quantity_base=payload.quantity_base,
base=(payload.base.value if payload.base is not None else None),
user=user,
)
except einkaufsliste.EintragFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
return eintrag_out(db, eintrag)
@router.patch("/entries/{entry_id}", response_model=ShoppingEntryOut)
def update_entry(
entry_id: int,
payload: ShoppingEntryPatch,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ShoppingEntryOut:
"""Abhaken oder die Menge korrigieren."""
eintrag = db.get(ShoppingEntry, entry_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
if payload.quantity_base is not None:
eintrag.quantity_base = payload.quantity_base
if payload.free_text is not None:
if eintrag.free_text is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Dieser Eintrag zeigt auf einen Artikel – sein Text lässt sich "
"nicht ändern.",
)
eintrag.free_text = payload.free_text.strip()[:120]
if payload.done is not None:
einkaufsliste.abhaken(db, eintrag, payload.done)
else:
db.commit()
db.refresh(eintrag)
return eintrag_out(db, eintrag)
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
eintrag = db.get(ShoppingEntry, entry_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
einkaufsliste.loeschen(db, eintrag)
@router.post("/clear-done", response_model=int)
def clear_done(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> int:
"""Nach dem Einkauf aufräumen: alles Abgehakte weg.
Bewusst ein POST und kein DELETE auf einer Sammlung: Es löscht zwei
verschiedene Dinge (eigene Einträge und Haken an gerechneten Posten), und
das ist eine Handlung, keine Ressource.
"""
return einkaufsliste.erledigte_entfernen(db)
@router.put("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
def set_check(
key: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
"""Einen gerechneten Posten abhaken – dauerhaft.
Der Schlüssel kommt aus der Oberfläche („p12", „g3", „lKUECHEp12"). Der
Server prüft ihn nicht gegen die gerechnete Liste: Sie ändert sich mit jedem
Einlagern, und ein Haken auf einen Posten, der gerade nicht mehr fehlt,
schadet nichts — er verschwindet mit „Erledigte entfernen".
"""
try:
einkaufsliste.haken_setzen(db, key, user)
except einkaufsliste.EintragFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
@router.delete("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
def clear_check(
key: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
einkaufsliste.haken_loesen(db, key)
+12 -3
View File
@@ -42,7 +42,9 @@ from ..services.conversion import (
display_unit_info,
group_min_context,
)
from ..services import einkaufsliste
from ..services import gruppen as gruppen_graph
from .shopping import eintrag_out
from ..services.stock import (
current_stock,
descendant_location_ids,
@@ -469,13 +471,20 @@ def shopping_list_by_location(
def shopping_list_all(
db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> ShoppingListAll:
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
Erspart drei getrennte Abfragen."""
"""Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe
(Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die
dauerhaft gesetzten Haken. Erspart der Oberfläche fünf Abfragen.
Gerechnet und gespeichert stehen bewusst nebeneinander: Die Bedarfe aus den
Mindestbeständen entstehen bei jedem Aufruf neu, die eigenen Einträge
bleiben liegen, bis sie abgehakt sind.
"""
return ShoppingListAll(
products=shopping_list(db, user),
groups=group_shopping_list(db, user),
by_location=shopping_list_by_location(db, user),
entries=[eintrag_out(db, e) for e in einkaufsliste.eintraege(db)],
checked=einkaufsliste.abgehakte(db),
)
+51 -2
View File
@@ -1254,12 +1254,61 @@ class LocationNeeds(BaseModel):
groups: list[LocationNeedGroup] = []
# ---- Gespeicherte Einkaufsliste ----
class ShoppingEntryIn(BaseModel):
"""Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext."""
product_id: int | None = None
group_id: int | None = None
free_text: str | None = Field(default=None, max_length=120)
quantity_base: float | None = Field(default=None, gt=0)
base: BaseUnit | None = None
class ShoppingEntryPatch(BaseModel):
"""Abhaken oder die Menge korrigieren."""
done: bool | None = None
quantity_base: float | None = Field(default=None, gt=0)
free_text: str | None = Field(default=None, max_length=120)
class ShoppingEntryOut(BaseModel):
"""Ein gespeicherter Eintrag, fertig für die Liste."""
id: int
art: str # product | group | text
product_id: int | None = None
group_id: int | None = None
free_text: str | None = None
name: str = ""
quantity_base: float | None = None
base: BaseUnit | None = None
# „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten.
menge_text: str = ""
source: str = "manual" # manual | recipe
recipe_slug: str | None = None
recipe_name: str | None = None
done: bool = False
created_at: datetime
class ShoppingFromRecipe(BaseModel):
"""Was die Übernahme eines Rezepts bewirkt hat."""
entries: list[ShoppingEntryOut] = []
# Wie viele davon schon dastanden und nur neu gerechnet wurden.
aktualisiert: int = 0
# Zutaten ohne Zuordnung, die als Freitext draufkamen.
freitext: int = 0
class ShoppingListAll(BaseModel):
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
die Oberfläche zusammenführt."""
"""Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die
eigenen Einträge und die dauerhaft gesetzten Haken."""
products: list[ShoppingItem] = []
groups: list[GroupShoppingItem] = []
by_location: list[LocationNeeds] = []
# Eigene und aus Rezepten übernommene Einträge.
entries: list[ShoppingEntryOut] = []
# Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12").
checked: list[str] = []
class LocationContentEntry(BaseModel):
+287
View File
@@ -0,0 +1,287 @@
"""Die gespeicherte Seite der Einkaufsliste.
Gerechnet wird die Liste in ``routers/views.py`` – Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt so. Hier
liegt daneben das, was jemand **bewusst** draufgesetzt hat: eigene Einträge,
übernommene Rezeptzutaten, und das dauerhafte Abhaken der gerechneten
Vorschläge.
Beides getrennt zu halten ist Absicht. Die gerechneten Posten dürfen keine
Zeilen in der Datenbank bekommen: Sie ändern sich mit jedem Einlagern, und eine
materialisierte Kopie würde sofort von der Wirklichkeit abweichen. Was gemerkt
wird, ist nur der Haken daran.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from ..models import (
Group,
Product,
ShoppingCheck,
ShoppingEntry,
User,
)
from .einkaufsliste_text import fehlmengen_text
class EintragFehler(ValueError):
"""Unbrauchbarer Eintrag – der Router macht daraus eine 400."""
def _jetzt() -> datetime:
return datetime.now(timezone.utc)
# --------------------------------------------------------------------------
# Eigene Einträge
# --------------------------------------------------------------------------
def eintraege(db: Session, *, mit_erledigten: bool = True) -> list[ShoppingEntry]:
"""Alle gespeicherten Einträge, offene zuerst.
Zeigt ein Eintrag auf einen Artikel oder eine Gruppe, die es nicht mehr
gibt, fällt er hier still heraus: Auf SQLite greift kein Fremdschlüssel, und
einen gelöschten Artikel kann man nicht einkaufen.
"""
reihen = db.query(ShoppingEntry).order_by(
ShoppingEntry.done, ShoppingEntry.created_at
).all()
gueltig = [e for e in reihen if _ziel_existiert(db, e)]
if mit_erledigten:
return gueltig
return [e for e in gueltig if not e.done]
def _ziel_existiert(db: Session, eintrag: ShoppingEntry) -> bool:
if eintrag.product_id is not None:
return db.get(Product, eintrag.product_id) is not None
if eintrag.group_id is not None:
return db.get(Group, eintrag.group_id) is not None
return bool(eintrag.free_text)
def bezeichnung(db: Session, eintrag: ShoppingEntry) -> str:
"""Was auf dem Zettel steht."""
if eintrag.product_id is not None:
produkt = db.get(Product, eintrag.product_id)
return produkt.name if produkt is not None else ""
if eintrag.group_id is not None:
gruppe = db.get(Group, eintrag.group_id)
return gruppe.name if gruppe is not None else ""
return eintrag.free_text or ""
@dataclass
class _Menge:
"""Die Form, die ``fehlmengen_text`` erwartet.
Umweg mit Absicht: So formatiert die Einkaufsliste ihre Mengen mit genau
derselben Funktion wie der Rezeptabgleich („2 Packungen" statt „1000 ml",
aufgerundet auf ganze Gebinde). Zwei Formatierungen wuerden auseinander
laufen.
"""
art: str
ziel_id: int | None
fehlt_base: float | None
basis_einheit: str
def mengen_text(db: Session, eintrag: ShoppingEntry) -> str:
"""„2 Packungen" bzw. „150 g" – leer, wenn keine Menge dransteht."""
if not eintrag.quantity_base:
return ""
art = "product" if eintrag.product_id is not None else "group"
return fehlmengen_text(db, _Menge(
art=art, ziel_id=eintrag.product_id or eintrag.group_id,
fehlt_base=eintrag.quantity_base, basis_einheit=eintrag.base or "",
))
def anlegen(
db: Session,
*,
product_id: int | None = None,
group_id: int | None = None,
free_text: str | None = None,
quantity_base: float | None = None,
base: str | None = None,
source: str = "manual",
recipe_slug: str | None = None,
recipe_name: str | None = None,
user: User | None = None,
) -> ShoppingEntry:
"""Einen Eintrag anlegen. Genau ein Ziel, und es muss existieren."""
text = (free_text or "").strip() or None
ziele = [z for z in (product_id, group_id, text) if z is not None]
if len(ziele) != 1:
raise EintragFehler(
"Ein Eintrag zeigt auf genau eines: einen Artikel, eine Gruppe oder "
"einen freien Text."
)
if product_id is not None and db.get(Product, product_id) is None:
raise EintragFehler("Diesen Artikel gibt es nicht.")
if group_id is not None and db.get(Group, group_id) is None:
raise EintragFehler("Diese Gruppe gibt es nicht.")
if quantity_base is not None and quantity_base <= 0:
raise EintragFehler("Eine Menge muss größer als 0 sein.")
eintrag = ShoppingEntry(
product_id=product_id, group_id=group_id, free_text=text,
quantity_base=quantity_base, base=base, source=source,
recipe_slug=recipe_slug, recipe_name=recipe_name,
created_by=(user.id if user is not None else None),
)
db.add(eintrag)
db.commit()
db.refresh(eintrag)
return eintrag
def abhaken(db: Session, eintrag: ShoppingEntry, done: bool) -> ShoppingEntry:
eintrag.done = done
eintrag.done_at = _jetzt() if done else None
db.commit()
db.refresh(eintrag)
return eintrag
def loeschen(db: Session, eintrag: ShoppingEntry) -> None:
db.delete(eintrag)
db.commit()
def erledigte_entfernen(db: Session) -> int:
"""Alles Abgehakte weg – eigene Einträge und die Haken an den gerechneten.
Ein Durchgang nach dem Einkauf. Bewusst beides zusammen: Für den Nutzer ist
es eine Liste, auch wenn sie aus zwei Quellen kommt.
"""
anzahl = 0
for eintrag in db.query(ShoppingEntry).filter(ShoppingEntry.done.is_(True)).all():
db.delete(eintrag)
anzahl += 1
for haken in db.query(ShoppingCheck).all():
db.delete(haken)
anzahl += 1
db.commit()
return anzahl
# --------------------------------------------------------------------------
# Haken an den gerechneten Posten
# --------------------------------------------------------------------------
def abgehakte(db: Session) -> list[str]:
return [h.key for h in db.query(ShoppingCheck).all()]
def haken_setzen(db: Session, key: str, user: User | None = None) -> None:
sauber = (key or "").strip()[:80]
if not sauber:
raise EintragFehler("Ohne Kennung lässt sich kein Haken merken.")
if db.get(ShoppingCheck, sauber) is None:
db.add(ShoppingCheck(
key=sauber, user_id=(user.id if user is not None else None),
))
db.commit()
def haken_loesen(db: Session, key: str) -> None:
haken = db.get(ShoppingCheck, (key or "").strip()[:80])
if haken is not None:
db.delete(haken)
db.commit()
# --------------------------------------------------------------------------
# Aus einem Rezept übernehmen
# --------------------------------------------------------------------------
def aus_rezept(
db: Session,
pruefungen: list,
*,
slug: str,
name: str,
user: User | None = None,
) -> tuple[list[ShoppingEntry], int]:
"""Was für dieses Rezept fehlt, auf die Liste setzen.
Übernommen wird, was fehlt oder zu wenig ist — mit der **Fehlmenge**, nicht
der Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g.
Zutaten ohne Zuordnung kommen als Freitext drauf. Das ist der wichtigere
Teil, als es klingt: Gerade die unbekannte Zutat ist die, die man vergisst.
Je Rezept und Ziel gibt es höchstens einen offenen Eintrag. Ein zweiter
Druck auf den Knopf — etwa nach dem Hochdrehen der Portionen — rechnet die
Menge neu, statt die Liste zu verdoppeln. Über verschiedene Rezepte hinweg
bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen.
Rückgabe: die betroffenen Einträge und wie viele davon aktualisiert (statt
neu angelegt) wurden.
"""
betroffen: list[ShoppingEntry] = []
aktualisiert = 0
for p in pruefungen:
if p.status in ("fehlt", "zu_wenig"):
menge = p.fehlt_base if p.fehlt_base else None
vorhanden = _offener_eintrag(
db, slug, product_id=(p.ziel_id if p.art == "product" else None),
group_id=(p.ziel_id if p.art == "group" else None),
)
if vorhanden is not None:
vorhanden.quantity_base = menge
vorhanden.base = p.basis_einheit or None
aktualisiert += 1
betroffen.append(vorhanden)
continue
betroffen.append(anlegen(
db,
product_id=(p.ziel_id if p.art == "product" else None),
group_id=(p.ziel_id if p.art == "group" else None),
quantity_base=menge, base=p.basis_einheit or None,
source="recipe", recipe_slug=slug, recipe_name=name, user=user,
))
elif p.status == "kein_treffer":
text = (p.food or p.text or "").strip()[:120]
if not text:
continue
vorhanden = _offener_eintrag(db, slug, free_text=text)
if vorhanden is not None:
aktualisiert += 1
betroffen.append(vorhanden)
continue
betroffen.append(anlegen(
db, free_text=text, source="recipe",
recipe_slug=slug, recipe_name=name, user=user,
))
db.commit()
return betroffen, aktualisiert
def _offener_eintrag(
db: Session,
slug: str,
*,
product_id: int | None = None,
group_id: int | None = None,
free_text: str | None = None,
) -> ShoppingEntry | None:
"""Ein noch nicht abgehakter Eintrag dieses Rezepts für dasselbe Ziel."""
frage = db.query(ShoppingEntry).filter(
ShoppingEntry.recipe_slug == slug,
ShoppingEntry.done.is_(False),
)
if product_id is not None:
frage = frage.filter(ShoppingEntry.product_id == product_id)
elif group_id is not None:
frage = frage.filter(ShoppingEntry.group_id == group_id)
else:
frage = frage.filter(ShoppingEntry.free_text == free_text)
return frage.first()