Vorrat: eigene Läden mit Produktzuordnung

- Läden pro Liste (Tabelle stores): anlegen, umbenennen, sortieren,
  löschen; je Laden eigene Reihenfolge der Abteilungen
- Produkte einem Laden zuordnen (Tabelle store_products, pro Liste,
  bleibt nach dem Einkauf gemerkt); gelöschter Laden → Produkte ohne Laden
- Einkaufsliste "Nach Laden": nach Laden gruppiert, darin nach Abteilung;
  Filter "Alle | Laden | Ohne Laden", im Filter Hinzugefügtes wird diesem
  Laden zugeordnet
- Läden fremder privater Listen sind nicht nutzbar (Tests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:11:39 +02:00
1 parent bbf740603e
commit fef9c8f16e
9 files changed
+393 -33

No files matched your search

+18
View File
@@ -148,6 +148,24 @@ CREATE TABLE IF NOT EXISTS products (
category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet
);
-- Läden einer Liste (z. B. Aldi, dm): Produkte lassen sich einem Laden zuordnen, die Einkaufsliste
-- gruppiert danach; jeder Laden kann eine eigene Reihenfolge der Abteilungen haben
CREATE TABLE IF NOT EXISTS stores (
id INTEGER PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position REAL NOT NULL,
category_order TEXT -- JSON wie bei shopping_lists; NULL = Reihenfolge der Liste
);
-- In welchem Laden ein Produkt (pro Liste) gekauft wird
CREATE TABLE IF NOT EXISTS store_products (
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
key TEXT NOT NULL,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, key)
);
-- 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,
+112 -2
View File
@@ -25,6 +25,8 @@ MAX_QUANTITY = 999
MAX_PRODUCTS = 2000
MAX_LIST_NAME = 40
MAX_LISTS = 50
MAX_STORES = 30
MAX_STORE_NAME = 40
def _item(row) -> dict:
@@ -37,6 +39,7 @@ def _item(row) -> dict:
"unit": row["unit"],
"checked": bool(row["checked"]),
"category": row["category"] or guess(row["name"]),
"storeId": row["store_id"],
"updatedAt": row["updated_at"],
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None,
}
@@ -187,6 +190,10 @@ def _shopping_list(conn, row, user: dict) -> dict:
"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"]),
"stores": [
{"id": s["id"], "name": s["name"], "categoryOrder": _category_order(s["category_order"] or row["category_order"])}
for s in conn.execute("SELECT * FROM stores WHERE list_id = ? ORDER BY position, id", (row["id"],))
],
}
@@ -234,9 +241,10 @@ def _state(conn, user: dict) -> dict:
ids = [r["id"] for r in lists]
marks = ",".join("?" * len(ids))
items = conn.execute(
f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i
f"""SELECT i.*, u.display_name AS updated_by_name, p.category, sp.store_id FROM household_items i
LEFT JOIN users u ON u.id = i.updated_by
LEFT JOIN products p ON p.key = i.key
LEFT JOIN store_products sp ON sp.list_id = i.list_id AND sp.key = i.key
WHERE i.list_id IN ({marks})""",
ids,
).fetchall()
@@ -295,7 +303,11 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
quantity = _quantity(data.get("quantity", 1), unit)
with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), user)["id"]
_add(conn, _canonical_name(conn, name, list_id), list_, list_id, quantity, unit, user["id"])
name = _canonical_name(conn, name, list_id)
_add(conn, name, list_, list_id, quantity, unit, user["id"])
# Im Laden-Filter hinzugefügt: Produkt gleich diesem Laden zuordnen
if data.get("storeId") is not None:
_assign_store(conn, list_id, _key(name), _get_store(conn, data["storeId"], user, list_id)["id"])
return _state(conn, user)
@@ -488,3 +500,101 @@ def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depend
_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)
# ---------- Läden ----------
def _get_store(conn, store_id, user: dict, list_id=None):
"""Laden einer Liste, die das Konto sieht (optional: muss zu list_id gehören)."""
if isinstance(store_id, bool) or not isinstance(store_id, int):
raise ValidationError("Ungültiger Laden.")
row = conn.execute("SELECT * FROM stores WHERE id = ?", (store_id,)).fetchone()
if not row or (list_id is not None and row["list_id"] != list_id):
raise HTTPException(404, "Laden nicht gefunden.")
_get_list(conn, row["list_id"], user)
return row
def _assign_store(conn, list_id: int, key: str, store_id) -> None:
if store_id is None:
conn.execute("DELETE FROM store_products WHERE list_id = ? AND key = ?", (list_id, key))
else:
conn.execute(
"""INSERT INTO store_products (list_id, key, store_id) VALUES (?, ?, ?)
ON CONFLICT (list_id, key) DO UPDATE SET store_id = excluded.store_id""",
(list_id, key, store_id),
)
@router.post("/api/haushalt/listen/{list_id}/laeden", status_code=201)
def create_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Laden anlegen (alle, die die Liste sehen, dürfen Läden verwalten)."""
name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME)
with transaction() as conn:
_get_list(conn, list_id, user)
if conn.execute("SELECT COUNT(*) FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0] >= MAX_STORES:
raise ValidationError(f"Maximal {MAX_STORES} Läden pro Liste.")
if conn.execute("SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE", (list_id, name)).fetchone():
raise ValidationError("Diesen Laden gibt es in der Liste schon.")
position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0]
store_id = conn.execute(
"INSERT INTO stores (list_id, name, position) VALUES (?, ?, ?)", (list_id, name, position)
).lastrowid
return {**_state(conn, user), "created": store_id}
@router.patch("/api/haushalt/laeden/{store_id}")
def update_store(store_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Laden umbenennen ({name}) oder seine Abteilungs-Reihenfolge setzen ({categoryOrder}, null = wie die Liste)."""
with transaction() as conn:
row = _get_store(conn, store_id, user)
if "name" in data:
name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME)
if conn.execute(
"SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE AND id != ?", (row["list_id"], name, store_id)
).fetchone():
raise ValidationError("Diesen Laden gibt es in der Liste schon.")
conn.execute("UPDATE stores SET name = ? WHERE id = ?", (name, store_id))
if "categoryOrder" in data:
order = data["categoryOrder"]
if order is not None and (not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS)):
raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.")
conn.execute("UPDATE stores SET category_order = ? WHERE id = ?", (json.dumps(order) if order else None, store_id))
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/laeden-reihenfolge")
def order_stores(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Reihenfolge der Läden ({order: [ids]}) – so erscheinen sie in der Einkaufsliste."""
with transaction() as conn:
_get_list(conn, list_id, user)
ids = [r["id"] for r in conn.execute("SELECT id FROM stores WHERE list_id = ?", (list_id,))]
order = data.get("order")
if not isinstance(order, list) or sorted(order) != sorted(ids):
raise ValidationError("Die Reihenfolge muss jeden Laden genau einmal enthalten.")
conn.executemany("UPDATE stores SET position = ? WHERE id = ?", [(i, s) for i, s in enumerate(order)])
return _state(conn, user)
@router.delete("/api/haushalt/laeden/{store_id}")
def delete_store(store_id: int, user: dict = Depends(current_user)):
"""Laden löschen; seine Produkte bleiben auf der Liste, nur ohne Laden."""
with transaction() as conn:
_get_store(conn, store_id, user)
conn.execute("DELETE FROM stores WHERE id = ?", (store_id,))
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/produkt-laden")
def set_product_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Produkt einem Laden zuordnen ({name, storeId}); storeId null = kein Laden. Gilt für diese Liste."""
name = _clean_name(data.get("name"))
with transaction() as conn:
_get_list(conn, list_id, user)
store_id = data.get("storeId")
if store_id is not None:
store_id = _get_store(conn, store_id, user, list_id)["id"]
_assign_store(conn, list_id, _key(name), store_id)
return _state(conn, user)
+64
View File
@@ -131,3 +131,67 @@ def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
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"
# ---------- Läden ----------
def new_store(client, list_id, name):
res = client.post(f"/api/haushalt/listen/{list_id}/laeden", json={"name": name})
assert res.status_code == 201, res.text
return res.json()["created"]
def stores(data, list_id):
return next(l for l in data["lists"] if l["id"] == list_id)["stores"]
def test_laeden_anlegen_sortieren_umbenennen_loeschen(login):
anna = login("anna")
lid = home(anna)
aldi, dm = new_store(anna, lid, "Aldi"), new_store(anna, lid, "dm")
assert anna.post(f"/api/haushalt/listen/{lid}/laeden", json={"name": "aldi"}).status_code == 400 # doppelt
data = anna.put(f"/api/haushalt/listen/{lid}/laeden-reihenfolge", json={"order": [dm, aldi]}).json()
assert [s["name"] for s in stores(data, lid)] == ["dm", "Aldi"]
data = anna.patch(f"/api/haushalt/laeden/{aldi}", json={"name": "Aldi Süd", "categoryOrder": list(reversed(KEYS))}).json()
s = next(x for x in stores(data, lid) if x["id"] == aldi)
assert s["name"] == "Aldi Süd" and s["categoryOrder"] == list(reversed(KEYS))
assert next(x for x in stores(data, lid) if x["id"] == dm)["categoryOrder"] == KEYS # Standard der Liste
data = anna.delete(f"/api/haushalt/laeden/{dm}").json()
assert [s["name"] for s in stores(data, lid)] == ["Aldi Süd"]
def test_produkt_laden_zuordnen_und_behalten(login):
anna = login("anna")
lid = home(anna)
dm = new_store(anna, lid, "dm")
milch = items(add(anna, lid, "Zahnpasta").json(), lid)[0]
assert milch["storeId"] is None
data = anna.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Zahnpasta", "storeId": dm}).json()
assert items(data, lid)[0]["storeId"] == dm
# Gekauft und später wieder auf die Liste: Laden bleibt gemerkt
anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True})
anna.post(f"/api/haushalt/listen/{lid}/einkauf-beenden")
data = add(anna, lid, "Zahnpasta").json()
assert [i["storeId"] for i in items(data, lid, "einkauf")] == [dm]
# Laden gelöscht → Produkt ohne Laden, bleibt aber auf der Liste
data = anna.delete(f"/api/haushalt/laeden/{dm}").json()
assert [i["storeId"] for i in items(data, lid, "einkauf")] == [None]
def test_im_laden_hinzufuegen(login):
anna = login("anna")
lid = home(anna)
aldi = new_store(anna, lid, "Aldi")
data = anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": aldi}).json()
assert items(data, lid)[0]["storeId"] == aldi
def test_laden_fremder_liste_nicht_nutzbar(login):
anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False)
fremd = new_store(anna, privat, "Geheimladen")
lid = home(ben)
assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404
assert ben.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Brot", "storeId": fremd}).status_code == 404
assert ben.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": fremd}).status_code == 404