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

+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}