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

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