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
+25
-2
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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}
|
||||
@@ -156,6 +156,7 @@ def test_problembilder_verhindern_start_nicht(conn):
|
||||
with conn:
|
||||
conn.execute("PRAGMA user_version = 1")
|
||||
db.init()
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 2
|
||||
from app import migrations
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS)
|
||||
finally:
|
||||
gesperrt.chmod(0o644)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Mindestbestand, Laden-Kategorien und Rezeptsammlung."""
|
||||
from app.kategorien import KEYS
|
||||
|
||||
from test_haushalt import add, items, new_list, state
|
||||
|
||||
|
||||
def home(client):
|
||||
return state(client)["lists"][0]["id"]
|
||||
|
||||
|
||||
def amounts(data, list_id, name):
|
||||
return {(i["list"], i["unit"]): i["quantity"] for i in items(data, list_id) if i["name"] == name}
|
||||
|
||||
|
||||
def set_min(client, list_id, name, minimum, unit="stk"):
|
||||
res = client.put(f"/api/haushalt/listen/{list_id}/mindestbestand", json={"name": name, "unit": unit, "minimum": minimum})
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
# ---------- Mindestbestand ----------
|
||||
|
||||
|
||||
def test_mindestbestand_fuellt_einkauf_auf(login):
|
||||
anna = login("anna")
|
||||
lid = home(anna)
|
||||
milch = items(add(anna, lid, "Milch", area="vorrat", quantity=3).json(), lid)[0]
|
||||
data = set_min(anna, lid, "Milch", 2)
|
||||
assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 3} # genug da
|
||||
data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"quantity": 1}).json()
|
||||
assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 1, ("einkauf", "stk"): 1}
|
||||
data = anna.delete(f"/api/haushalt/items/{milch['id']}").json() # alles weg → auf 2 auffüllen
|
||||
assert amounts(data, lid, "Milch") == {("einkauf", "stk"): 2}
|
||||
assert data["minimums"] == [{"listId": lid, "key": "milch", "name": "Milch", "unit": "stk", "minimum": 2}]
|
||||
|
||||
|
||||
def test_mindestbestand_aufgebraucht_und_sofort(login):
|
||||
anna = login("anna")
|
||||
lid = home(anna)
|
||||
mehl = items(add(anna, lid, "Mehl", area="vorrat", quantity=1, unit="kg").json(), lid)[0]
|
||||
set_min(anna, lid, "Mehl", 2.5, unit="kg") # schon darunter → sofort
|
||||
data = state(anna)
|
||||
assert amounts(data, lid, "Mehl") == {("vorrat", "kg"): 1, ("einkauf", "kg"): 1.5}
|
||||
data = anna.post(f"/api/haushalt/items/{mehl['id']}/move").json() # aufgebraucht
|
||||
assert amounts(data, lid, "Mehl") == {("einkauf", "kg"): 2.5}
|
||||
data = set_min(anna, lid, "Mehl", 0, unit="kg")
|
||||
assert data["minimums"] == []
|
||||
|
||||
|
||||
def test_mindestbestand_nur_mit_listenzugriff(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
res = ben.put(f"/api/haushalt/listen/{privat}/mindestbestand", json={"name": "x", "unit": "stk", "minimum": 1})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------- Kategorien ----------
|
||||
|
||||
|
||||
def test_kategorie_geraten_und_aenderbar(login):
|
||||
anna = login("anna")
|
||||
lid = home(anna)
|
||||
data = add(anna, lid, "Vollmilch").json()
|
||||
assert items(data, lid)[0]["category"] == "kuehl"
|
||||
assert data["productCategories"]["Vollmilch"] == "kuehl"
|
||||
data = anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "getraenke"}).json()
|
||||
assert items(data, lid)[0]["category"] == "getraenke"
|
||||
assert anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "quatsch"}).status_code == 400
|
||||
|
||||
|
||||
def test_kategorie_privater_produkte_nicht_aenderbar(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
add(anna, privat, "Geheimzutat")
|
||||
assert ben.put("/api/haushalt/produkte/kategorie", json={"name": "Geheimzutat", "category": "obst"}).status_code == 404
|
||||
|
||||
|
||||
def test_laden_reihenfolge(login):
|
||||
anna = login("anna")
|
||||
lid = home(anna)
|
||||
assert state(anna)["lists"][0]["categoryOrder"] == KEYS
|
||||
order = list(reversed(KEYS))
|
||||
data = anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": order}).json()
|
||||
assert data["lists"][0]["categoryOrder"] == order
|
||||
assert anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": KEYS[:3]}).status_code == 400
|
||||
|
||||
|
||||
# ---------- Rezeptsammlung ----------
|
||||
|
||||
|
||||
def save(client, title, lines, source=None):
|
||||
res = client.post("/api/haushalt/rezepte", json={"title": title, "lines": lines, "source": source, "servings": 2})
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()["id"]
|
||||
|
||||
|
||||
def test_rezepte_nach_vorrat_sortiert(login):
|
||||
anna = login("anna")
|
||||
lid = home(anna)
|
||||
for name in ("Nudeln", "Tomaten"):
|
||||
add(anna, lid, name, area="vorrat")
|
||||
save(anna, "Pasta", ["250 g Nudeln", "3 Tomaten", "1 Prise Salz"]) # Salz zählt nicht
|
||||
save(anna, "Kuchen", ["200 g Mehl", "3 Eier", "100 g Zucker"])
|
||||
res = anna.get("/api/haushalt/rezepte", params={"listId": lid}).json()
|
||||
assert [(r["title"], r["have"], r["total"]) for r in res] == [("Pasta", 2, 2), ("Kuchen", 0, 3)]
|
||||
assert res[1]["missing"] == ["Mehl", "Eier", "Zucker"]
|
||||
|
||||
|
||||
def test_rezept_gleicher_link_wird_aktualisiert(login):
|
||||
anna = login("anna")
|
||||
a = save(anna, "Alt", ["1 Ei"], source="https://example.org/r")
|
||||
b = save(anna, "Neu", ["2 Eier"], source="https://example.org/r")
|
||||
assert a == b
|
||||
detail = anna.get(f"/api/haushalt/rezepte/{a}").json()
|
||||
assert detail["title"] == "Neu" and detail["lines"] == ["2 Eier"]
|
||||
|
||||
|
||||
def test_rezept_loeschen_nur_ersteller_oder_admin(login):
|
||||
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
|
||||
a = save(anna, "A", ["1 Ei"])
|
||||
b = save(anna, "B", ["1 Ei"])
|
||||
assert ben.delete(f"/api/haushalt/rezepte/{a}").status_code == 403
|
||||
assert anna.delete(f"/api/haushalt/rezepte/{a}").status_code == 200
|
||||
assert chef.delete(f"/api/haushalt/rezepte/{b}").status_code == 200
|
||||
|
||||
|
||||
def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
add(anna, privat, "Kondome", area="vorrat")
|
||||
save(ben, "Test", ["2 Kondom"])
|
||||
res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json()
|
||||
assert res[0]["missing"] == ["Kondom"] # nicht Annas Schreibweise "Kondome"
|
||||
Reference in new issue
Block a user