Vorrat: Mindestbestand, Sortierung nach Laden, Rezeptsammlung mit Vorschlägen

- Mindestbestand pro Liste/Produkt/Einheit (Tabelle stock_minimums): fällt
  der Vorrat darunter, füllt der Server die Einkaufsliste genau bis zur
  fehlenden Menge auf (nach Ändern, Aufgebraucht, Löschen, Setzen)
- Laden-Kategorien (kategorien.py): automatische Zuordnung über Stichwörter
  (längstes Stichwort gewinnt), änderbar in der Zeile und im
  Vorschläge-Dialog; Einkaufsliste "A–Z" oder "Nach Laden" gruppiert;
  Reihenfolge der Abteilungen pro Liste
- Rezeptsammlung (Tabelle recipes): "☆ Merken" beim Abgleich, "Was kann ich
  kochen?" sortiert gemerkte Rezepte nach vorhandenen Zutaten (Salz & Co.
  zählen nicht, nur sichtbare Produkte), vollständiger Abgleich per Klick
- Migration Schritt 3 (Kategorien für bestehende Produkte); _add_column für
  Tabellen, die SCHEMA in alten Datenbanken schon neu angelegt hat
- Tests in test_vorrat_extras.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:17:52 +02:00
1 parent 9df0e38f52
commit 9835f5e69e
15 files changed
+879 -40

No files matched your search

+25 -2
View File
@@ -127,7 +127,8 @@ CREATE TABLE IF NOT EXISTS shopping_lists (
everyone INTEGER NOT NULL DEFAULT 1,
position REAL NOT NULL,
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_order TEXT -- JSON-Liste der Kategorien in Laden-Reihenfolge; NULL = Standard
);
CREATE TABLE IF NOT EXISTS shopping_list_members (
@@ -143,7 +144,29 @@ CREATE TABLE IF NOT EXISTS products (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
uses INTEGER NOT NULL DEFAULT 0,
last_used TEXT NOT NULL
last_used TEXT NOT NULL,
category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet
);
-- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste
CREATE TABLE IF NOT EXISTS stock_minimums (
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
key TEXT NOT NULL,
name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('stk', 'kg', 'l')),
minimum REAL NOT NULL CHECK (minimum > 0),
PRIMARY KEY (list_id, key, unit)
);
-- Gemerkte Rezepte (gemeinsam für alle Konten): Zutatenzeilen wie im Rezept, Abgleich passiert beim Abruf
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
source TEXT,
servings INTEGER,
lines TEXT NOT NULL, -- JSON-Liste der Zutatenzeilen
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
-- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf
+123 -8
View File
@@ -6,6 +6,7 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte
(Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
"""
import json
import math
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -13,6 +14,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
router = APIRouter()
@@ -34,6 +36,7 @@ def _item(row) -> dict:
"quantity": _out_quantity(row["quantity"], row["unit"]),
"unit": row["unit"],
"checked": bool(row["checked"]),
"category": row["category"] or guess(row["name"]),
"updatedAt": row["updated_at"],
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None,
}
@@ -93,7 +96,10 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
conn.execute(
"INSERT INTO products (key, name, uses, last_used, category) VALUES (?, ?, 1, ?, ?)",
(_key(name), name, now_iso(), guess(name)),
)
if list_id is not None:
conn.execute(
"""INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?)
@@ -103,16 +109,20 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
return name
def visible_products(conn, user: dict) -> list:
def _visible_product_rows(conn, user: dict) -> list:
"""Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst."""
rows = conn.execute(
f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp
f"""SELECT p.name, p.category, SUM(lp.uses) AS uses FROM list_products lp
JOIN products p ON p.key = lp.key
JOIN shopping_lists l ON l.id = lp.list_id
WHERE {_VISIBLE} GROUP BY p.key""",
_who(user),
).fetchall()
return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))]
return sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))
def visible_products(conn, user: dict) -> list:
return [r["name"] for r in _visible_product_rows(conn, user)]
def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None:
@@ -176,9 +186,19 @@ def _shopping_list(conn, row, user: dict) -> dict:
"members": members,
"createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
"canManage": _can_manage(row, user),
"categoryOrder": _category_order(row["category_order"]),
}
def _category_order(value) -> list:
"""Gespeicherte Reihenfolge, ergänzt um neu hinzugekommene Kategorien (am Ende)."""
try:
order = [k for k in json.loads(value) if k in CATEGORY_KEYS] if value else []
except ValueError:
order = []
return order + [k for k in CATEGORY_KEYS if k not in order]
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME)
@@ -212,20 +232,53 @@ def _get_item(conn, item_id: int, user: dict):
def _state(conn, user: dict) -> dict:
lists = _visible_lists(conn, user)
ids = [r["id"] for r in lists]
marks = ",".join("?" * len(ids))
items = conn.execute(
f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i
LEFT JOIN users u ON u.id = i.updated_by
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
LEFT JOIN products p ON p.key = i.key
WHERE i.list_id IN ({marks})""",
ids,
).fetchall()
minimums = conn.execute(f"SELECT * FROM stock_minimums WHERE list_id IN ({marks}) ORDER BY name", ids).fetchall()
products = _visible_product_rows(conn, user)
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn),
"products": visible_products(conn, user),
"products": [r["name"] for r in products],
"productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products},
"categories": [{"key": k, "title": t} for k, t in CATEGORIES],
"minimums": [
{"listId": r["list_id"], "key": r["key"], "name": r["name"], "unit": r["unit"], "minimum": _out_quantity(r["minimum"], r["unit"])}
for r in minimums
],
}
# ---------- Mindestbestand ----------
def _refill(conn, list_id: int, key: str, unit: str, user_id: int) -> None:
"""Liegt der Vorrat unter dem Mindestbestand, die Einkaufsliste bis zur fehlenden Menge auffüllen."""
minimum = conn.execute(
"SELECT name, minimum FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, key, unit)
).fetchone()
if not minimum:
return
def amount(list_):
row = conn.execute(
"SELECT quantity FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ?", (list_id, key, list_, unit)
).fetchone()
return row["quantity"] if row else 0
needed = minimum["minimum"] - amount("vorrat") - amount("einkauf")
if unit == "stk":
needed = math.ceil(needed)
needed = round(needed, 2)
if needed > 0:
_add(conn, minimum["name"], "einkauf", list_id, min(needed, MAX_QUANTITY), unit, user_id)
@router.get("/api/haushalt")
def get_household(user: dict = Depends(current_user)):
with transaction() as conn:
@@ -280,6 +333,9 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre
"UPDATE household_items SET quantity = ?, unit = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(quantity, unit, now_iso(), user["id"], item_id),
)
if row["list"] == "vorrat":
for u in {row["unit"], unit}: # bei Einheitenwechsel beide Einheiten prüfen
_refill(conn, row["list_id"], row["key"], u, user["id"])
return _state(conn, user)
@@ -293,6 +349,7 @@ def move_item(item_id: int, user: dict = Depends(current_user)):
quantity = row["quantity"] if target == "vorrat" else 1
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
_add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"])
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -315,8 +372,10 @@ def finish_shopping(list_id: int, user: dict = Depends(current_user)):
@router.delete("/api/haushalt/items/{item_id}")
def delete_item(item_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
_get_item(conn, item_id, user)
row = _get_item(conn, item_id, user)
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
if row["list"] == "vorrat":
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -373,3 +432,59 @@ def delete_product(name: str, user: dict = Depends(current_user)):
"DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name))
)
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/mindestbestand")
def set_minimum(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Mindestbestand setzen ({name, unit, minimum}); minimum 0 oder null entfernt ihn."""
name = _clean_name(data.get("name"))
unit = _unit(data.get("unit"))
with transaction() as conn:
_get_list(conn, list_id, user)
if data.get("minimum") in (None, 0):
conn.execute("DELETE FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, _key(name), unit))
return _state(conn, user)
minimum = _quantity(data.get("minimum"), unit)
name = _canonical_name(conn, name, list_id)
conn.execute(
"""INSERT INTO stock_minimums (list_id, key, name, unit, minimum) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, key, unit) DO UPDATE SET minimum = excluded.minimum, name = excluded.name""",
(list_id, _key(name), name, unit, minimum),
)
_refill(conn, list_id, _key(name), unit, user["id"])
return _state(conn, user)
# ---------- Laden-Kategorien ----------
@router.put("/api/haushalt/produkte/kategorie")
def set_category(data: dict = Body(...), user: dict = Depends(current_user)):
"""Kategorie eines Produkts ändern – nur für Produkte aus Listen, die das Konto sieht."""
if data.get("category") not in CATEGORY_KEYS:
raise ValidationError("Unbekannte Kategorie.")
with transaction() as conn:
ids = [r["id"] for r in _visible_lists(conn, user)]
key = _key(_clean_name(data.get("name")))
visible = conn.execute(
f"SELECT 1 FROM household_items WHERE key = ? AND list_id IN ({','.join('?' * len(ids))}) "
f"UNION SELECT 1 FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})",
(key, *ids, key, *ids),
).fetchone()
if not visible:
raise HTTPException(404, "Produkt nicht gefunden.")
conn.execute("UPDATE products SET category = ? WHERE key = ?", (data["category"], key))
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/reihenfolge")
def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Reihenfolge der Kategorien für den Laden dieser Liste (alle, die die Liste sehen, dürfen sie anpassen)."""
order = data.get("order")
if not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS):
raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.")
with transaction() as conn:
_get_list(conn, list_id, user)
conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id))
return _state(conn, user)
+88
View File
@@ -0,0 +1,88 @@
"""Laden-Kategorien für die Einkaufsliste und eine Zuordnung neuer Produkte über Stichwörter.
Die Zuordnung ist nur ein Vorschlag: Jedes Produkt lässt sich in der Oberfläche umsortieren,
die Reihenfolge der Kategorien kann jede Liste an ihren Laden anpassen.
"""
import re
# Standard-Reihenfolge = typischer Weg durch einen Supermarkt
CATEGORIES = [
("obst", "Obst & Gemüse"),
("brot", "Brot & Backwaren"),
("kuehl", "Kühlregal"),
("fleisch", "Fleisch & Fisch"),
("tiefkuehl", "Tiefkühl"),
("vorrat", "Nudeln, Reis & Konserven"),
("backen", "Backen & Gewürze"),
("suess", "Süßes & Snacks"),
("getraenke", "Getränke"),
("drogerie", "Drogerie & Haushalt"),
("sonstiges", "Sonstiges"),
]
KEYS = [k for k, _ in CATEGORIES]
# Stichwörter (Wortanfang oder ganzes Wort, klein geschrieben); das erste passende gewinnt
_WORDS = {
"tiefkuehl": ["tiefkühl", "tk-", "tk ", "pizza", "eis", "pommes", "fischstäbchen", "rahmspinat"],
"obst": [
"apfel", "äpfel", "banane", "birne", "orange", "zitrone", "limette", "mandarine", "traube", "beere", "erdbeer",
"kiwi", "mango", "ananas", "melone", "pfirsich", "nektarine", "kirsche", "pflaume", "avocado", "tomate",
"gurke", "salat", "paprika", "zwiebel", "knoblauch", "karotte", "möhre", "kartoffel", "zucchini", "aubergine",
"brokkoli", "blumenkohl", "kohl", "spinat", "lauch", "porree", "sellerie", "pilz", "champignon", "ingwer",
"petersilie", "schnittlauch", "basilikum", "dill", "kräuter", "radieschen", "rucola", "mais", "kürbis",
"rote bete", "spargel", "fenchel", "frühlingszwiebel", "obst", "gemüse", "lorbeer",
],
"brot": ["brot", "brötchen", "toast", "baguette", "croissant", "brezel", "laugen", "knäcke", "kuchen", "semmel"],
"kuehl": [
"milch", "joghurt", "quark", "käse", "butter", "sahne", "schmand", "crème", "creme fraiche", "frischkäse",
"mozzarella", "feta", "parmesan", "gouda", "skyr", "kefir", "ei", "eier", "eigelb", "eiweiß", "margarine", "pudding", "tofu",
"hefe", "aufschnitt", "wurst", "schinken", "salami",
],
"fleisch": [
"fleisch", "hack", "hähnchen", "huhn", "pute", "rind", "schwein", "steak", "schnitzel", "filet", "lachs",
"fisch", "thunfisch", "garnele", "speck", "bratwurst", "würstchen", "kotelett", "gulasch",
],
"vorrat": [
"nudel", "spaghetti", "pasta", "penne", "reis", "linsen", "bohnen", "kichererbsen", "konserve", "dose",
"tomatenmark", "passierte", "brühe", "fond", "couscous", "bulgur", "haferflocken", "müsli", "cornflakes",
"ketchup", "senf", "mayonnaise", "essig", "öl", "olivenöl", "soße", "sauce", "pesto", "honig", "marmelade",
"nutella", "erdnussbutter", "suppe",
],
"backen": [
"mehl", "zucker", "backpulver", "natron", "vanille", "puderzucker", "speisestärke", "kakao", "salz", "pfeffer",
"zimt", "paprikapulver", "gewürz", "curry", "oregano", "muskat", "kümmel", "chili", "gelatine", "rosinen",
"mandeln", "nüsse", "walnüsse", "haselnüsse", "kokos", "streusel", "schokostreusel", "kuvertüre", "butterschmalz",
],
"suess": ["schokolade", "chips", "keks", "gummibärchen", "bonbon", "riegel", "popcorn", "nüsschen", "süßigkeit", "salzstangen"],
"getraenke": [
"wasser", "saft", "cola", "limo", "bier", "wein", "sekt", "kaffee", "tee", "sprudel", "schorle", "energy",
"rotwein", "weißwein", "smoothie",
],
"drogerie": [
"toilettenpapier", "klopapier", "küchenrolle", "taschentuch", "spülmittel", "waschmittel", "weichspüler",
"seife", "shampoo", "duschgel", "zahnpasta", "zahnbürste", "deo", "müllbeutel", "schwamm", "reiniger",
"creme", "pflaster", "windel", "batterie", "alufolie", "frischhaltefolie", "backpapier", "spülmaschine",
"tabs", "kondom", "test", "tampon", "binde", "rasier",
],
}
def guess(name: str) -> str:
"""Kategorie für einen Produktnamen raten. Das längste passende Stichwort gewinnt
("Tomatenmark" → Konserven statt "Tomate" → Gemüse). Stichwörter zählen am Wortanfang und
– ab 4 Buchstaben – auch am Wortende ("Vollmilch"); kurze (bis 3 Buchstaben) nur als ganzes Wort ("Ei", "Öl")."""
text = name.casefold()
words = re.findall(r"[\wäöüß]+", text)
best, best_len = "sonstiges", 0
for key, stems in _WORDS.items():
for stem in stems:
s = stem.strip()
if " " in s or s.endswith("-"):
hit = s in text
elif len(s) <= 3:
hit = s in words
else:
hit = any(w.startswith(s) or w.endswith(s) for w in words)
if hit and len(s) > best_len:
best, best_len = key, len(s)
return best
+21
View File
@@ -4,6 +4,8 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche
1. SCHEMA in db.py anpassen – neue Datenbanken entstehen direkt so und bekommen die höchste Nummer.
2. Unten an STEPS eine Funktion anhängen, die eine bestehende Datenbank vom vorigen Stand dorthin bringt.
Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer.
Achtung: Eine Tabelle, die es in einer alten Datenbank noch gar nicht gab, legt SCHEMA schon in der
neuesten Form an. Spalten deshalb mit _add_column() ergänzen (fügt nur hinzu, was fehlt).
Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen).
"""
import logging
@@ -14,6 +16,7 @@ from .common import ValidationError, now_iso
from .config import DATA_DIR
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
from .htmlclean import sanitize
from .kategorien import guess
log = logging.getLogger(__name__)
@@ -32,6 +35,12 @@ def upgrade(conn) -> None:
conn.execute(f"PRAGMA user_version = {number}")
def _add_column(conn, table: str, column: str, definition: str) -> None:
"""Spalte ergänzen, falls sie fehlt (table/column/definition stammen nur aus diesem Modul)."""
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def _rebuild(conn, table: str, definition: str, columns: str, select: str, params=()) -> None:
"""Tabelle neu aufbauen: neue Tabelle unter Zwischennamen anlegen, Daten kopieren, alte löschen, umbenennen.
@@ -229,8 +238,20 @@ def _v2_datenschutz(conn) -> None:
log.warning("Bild %s konnte nicht bereinigt werden: %s", f.name, e)
# ---------- Schritt 3: Laden-Kategorien ----------
def _v3_kategorien(conn) -> None:
"""Produkte bekommen eine Laden-Kategorie (geraten), Listen eine eigene Kategorie-Reihenfolge."""
_add_column(conn, "products", "category", "TEXT")
_add_column(conn, "shopping_lists", "category_order", "TEXT")
for row in conn.execute("SELECT key, name FROM products").fetchall():
conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"]))
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [
_v1_altbestand,
_v2_datenschutz,
_v3_kategorien,
]
+99 -2
View File
@@ -12,12 +12,12 @@ import socket
from urllib.parse import urljoin, urlsplit
import httpx
from fastapi import APIRouter, Body, Depends
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import transaction
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit, visible_products
from .common import ValidationError
from .common import ValidationError, clean_text, now_iso
router = APIRouter()
@@ -26,6 +26,8 @@ MAX_REDIRECTS = 3
TIMEOUT = 10
MAX_INGREDIENTS = 80
MAX_TEXT = 10_000
MAX_LINE = 300
MAX_RECIPES = 500
# ---------- Seite abrufen ----------
@@ -291,6 +293,8 @@ def _result(conn, user, title, source, servings, raw_ingredients) -> dict:
"source": source,
"servings": servings,
"ingredients": [match(i) for i in merge(parsed)],
# Zeilen wie im Rezept – damit lässt es sich merken und später neu abgleichen
"lines": [str(r)[:MAX_LINE] for r in raw_ingredients[:MAX_INGREDIENTS]],
}
@@ -360,3 +364,96 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
for name, quantity, unit in checked:
_add(conn, _canonical_name(conn, name, list_id), "einkauf", list_id, quantity, unit, user["id"])
return _state(conn, user)
# ---------- Rezeptsammlung: merken und nach Vorrat vorschlagen ----------
def _recipe_fields(data: dict) -> tuple:
title = clean_text(data.get("title"), "Titel", 200)
source = data.get("source") or None
if source is not None and (not isinstance(source, str) or not re.match(r"^https?://\S+$", source) or len(source) > 500):
raise ValidationError("Ungültiger Rezept-Link.")
servings = data.get("servings")
if servings is not None and (isinstance(servings, bool) or not isinstance(servings, int) or not 0 < servings <= 100):
servings = None
lines = data.get("lines")
if not isinstance(lines, list) or not lines or len(lines) > MAX_INGREDIENTS or not all(isinstance(x, str) for x in lines):
raise ValidationError("Das Rezept hat keine gültigen Zutaten.")
return title, source, servings, [x.strip()[:MAX_LINE] for x in lines if x.strip()]
def _suggestion(row, match, stock: set, user: dict) -> dict:
"""Wie viele Zutaten sind im Vorrat? Salz, Wasser & Co. und optionale Zutaten zählen nicht."""
parsed = merge([p for p in (parse_ingredient(x) for x in json.loads(row["lines"])) if p])
have, missing = 0, []
counted = [i for i in parsed if not i["staple"] and not i["optional"]]
for ing in counted:
names = [ing["name"], *ing["alternatives"]]
found = [m["product"] for m in (match({**ing, "name": n, "alternatives": []}) for n in names) if m["known"]]
if any(_key(f) in stock for f in found):
have += 1
else:
missing.append(found[0] if found else ing["name"])
return {
"id": row["id"], "title": row["title"], "source": row["source"], "servings": row["servings"],
"total": len(counted), "have": have, "missing": missing,
"canDelete": user["isAdmin"] or row["created_by"] == user["id"],
}
@router.get("/api/haushalt/rezepte")
def list_recipes(listId: int, user: dict = Depends(current_user)):
"""Gemerkte Rezepte, sortiert danach, wie viel davon im Vorrat der Liste ist."""
with transaction() as conn:
_get_list(conn, listId, user)
stock = {r["key"] for r in conn.execute(
"SELECT key FROM household_items WHERE list_id = ? AND list = 'vorrat'", (listId,)
)}
match = _matcher(conn, user)
rows = conn.execute("SELECT * FROM recipes ORDER BY title COLLATE NOCASE").fetchall()
result = [_suggestion(r, match, stock, user) for r in rows]
return sorted(result, key=lambda r: (-(r["have"] / r["total"] if r["total"] else 1), len(r["missing"]), r["title"].casefold()))
@router.post("/api/haushalt/rezepte", status_code=201)
def save_recipe(data: dict = Body(...), user: dict = Depends(current_user)):
"""Rezept merken; gleicher Link = dasselbe Rezept (wird aktualisiert)."""
title, source, servings, lines = _recipe_fields(data)
with transaction() as conn:
existing = conn.execute("SELECT id FROM recipes WHERE source = ?", (source,)).fetchone() if source else None
if existing:
conn.execute(
"UPDATE recipes SET title = ?, servings = ?, lines = ? WHERE id = ?",
(title, servings, json.dumps(lines, ensure_ascii=False), existing["id"]),
)
return {"id": existing["id"]}
if conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] >= MAX_RECIPES:
raise ValidationError(f"Es sind schon {MAX_RECIPES} Rezepte gemerkt. Bitte alte löschen.")
recipe_id = conn.execute(
"INSERT INTO recipes (title, source, servings, lines, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)",
(title, source, servings, json.dumps(lines, ensure_ascii=False), now_iso(), user["id"]),
).lastrowid
return {"id": recipe_id}
@router.get("/api/haushalt/rezepte/{recipe_id}")
def get_recipe(recipe_id: int, user: dict = Depends(current_user)):
"""Gemerktes Rezept vollständig abgleichen (ohne die Seite erneut abzurufen)."""
with transaction() as conn:
row = conn.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
if not row:
raise HTTPException(404, "Rezept nicht gefunden.")
return {**_result(conn, user, row["title"], row["source"], row["servings"], json.loads(row["lines"])), "id": row["id"]}
@router.delete("/api/haushalt/rezepte/{recipe_id}")
def delete_recipe(recipe_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
row = conn.execute("SELECT created_by FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
if not row:
raise HTTPException(404, "Rezept nicht gefunden.")
if not user["isAdmin"] and row["created_by"] != user["id"]:
raise HTTPException(403, "Nur wer das Rezept gemerkt hat (oder ein Administrator) kann es löschen.")
conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
return {"ok": True}