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:
1 parent
9df0e38f52
commit
9835f5e69e
15 files changed
+879
-40
No files matched your search
+99
-2
@@ -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}
|
||||
Reference in new issue
Block a user