Files
Vorrania/backend/app/routers/shopping.py
T
ScarriffleandClaude Opus 5 795e233a1b 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>
2026-10-02 14:30:00 +02:00

151 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)