Files
p42.f-w/backend/app/haushalt.py
T
guyg1474andClaude Opus 5.5 fef9c8f16e 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>
2026-10-15 20:11:39 +02:00

601 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Vorrat und Einkauf in mehreren Listen sowie Produktkatalog für einheitliche Namen.
Jede Liste (z. B. "Zuhause", "Urlaub") hat ihren eigenen Vorrat und ihre eigene Einkaufsliste.
Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte. Ändern
(Name, Sichtbarkeit) und löschen dürfen der Ersteller und Administratoren. Jedes Produkt steht pro Liste und Einheit
(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
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()
LISTS = ("vorrat", "einkauf")
UNITS = ("stk", "kg", "l")
MAX_NAME = 60
MAX_QUANTITY = 999
MAX_PRODUCTS = 2000
MAX_LIST_NAME = 40
MAX_LISTS = 50
MAX_STORES = 30
MAX_STORE_NAME = 40
def _item(row) -> dict:
return {
"id": row["id"],
"name": row["name"],
"list": row["list"],
"listId": row["list_id"],
"quantity": _out_quantity(row["quantity"], row["unit"]),
"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,
}
def _out_quantity(value, unit: str):
return int(value) if unit == "stk" else round(value, 2)
def _unit(value) -> str:
if value is None:
return "stk"
if value not in UNITS:
raise ValidationError("Unbekannte Einheit.")
return value
def _quantity(value, unit: str = "stk"):
"""Anzahl: ganze Zahl ab 1. kg/l: Kommazahl ab 0,01 (zwei Nachkommastellen)."""
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValidationError("Menge fehlt.")
if unit == "stk":
if value != int(value) or not 1 <= value <= MAX_QUANTITY:
raise ValidationError(f"Anzahl muss eine ganze Zahl zwischen 1 und {MAX_QUANTITY} sein.")
return int(value)
value = round(value, 2)
if not 0 < value <= MAX_QUANTITY:
raise ValidationError(f"Menge muss zwischen 0,01 und {MAX_QUANTITY} liegen.")
return value
def _clean_name(value) -> str:
return clean_text(value, "Produktname", MAX_NAME)
def _key(name: str) -> str:
# casefold() statt COLLATE NOCASE: SQLite vergleicht nur ASCII ohne Groß/klein ("Äpfel" ≠ "äpfel")
return name.casefold()
_UMLAUTS = str.maketrans({"ä": "a", "ö": "o", "ü": "u", "ß": "ss"})
def _sort_key(name: str) -> str:
# Deutsche Sortierung: "Äpfel" gehört zu A, nicht hinter Z
return _key(name).translate(_UMLAUTS)
def _canonical_name(conn, name: str, list_id=None) -> str:
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen; Nutzung in der Liste mitzählen."""
row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone()
if row:
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
name = row["name"]
else:
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
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, 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, ?)
ON CONFLICT (list_id, key) DO UPDATE SET uses = uses + 1, last_used = excluded.last_used""",
(list_id, _key(name), now_iso()),
)
return name
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, 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 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:
"""Produkt in den Vorrat bzw. Einkauf einer Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht.
Kommt ein schon abgehaktes Produkt erneut auf die Einkaufsliste, ist der Haken wieder weg (es fehlt ja noch etwas).
"""
conn.execute(
"""INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (list_id, key, list, unit) DO UPDATE SET
quantity = MIN(ROUND(quantity + excluded.quantity, 2), ?),
checked = 0,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by""",
(_key(name), name, list_, list_id, quantity, unit, now_iso(), user_id, MAX_QUANTITY),
)
# ---------- Listen und wer sie sieht ----------
# Sichtbar: für alle freigegeben, selbst angelegt oder als Mitglied eingetragen.
# Listen eines gelöschten Kontos ohne Mitglieder sehen Administratoren, damit sie nicht verwaist bleiben.
_VISIBLE = """(l.everyone = 1 OR l.created_by = :uid
OR EXISTS (SELECT 1 FROM shopping_list_members m WHERE m.list_id = l.id AND m.user_id = :uid)
OR (:admin AND l.created_by IS NULL))"""
def _who(user: dict) -> dict:
return {"uid": user["id"], "admin": int(user["isAdmin"])}
def _visible_lists(conn, user: dict) -> list:
return conn.execute(
f"""SELECT l.*, u.display_name AS created_by_name FROM shopping_lists l
LEFT JOIN users u ON u.id = l.created_by
WHERE {_VISIBLE} ORDER BY l.position, l.id""",
_who(user),
).fetchall()
def _get_list(conn, list_id, user: dict):
if isinstance(list_id, bool) or not isinstance(list_id, int):
raise ValidationError("Bitte eine Liste wählen.")
row = conn.execute(f"SELECT l.* FROM shopping_lists l WHERE l.id = :id AND {_VISIBLE}", {"id": list_id, **_who(user)}).fetchone()
if not row:
raise HTTPException(404, "Liste nicht gefunden – vielleicht wurde sie gelöscht oder nicht mit dir geteilt.")
return row
def _can_manage(row, user: dict) -> bool:
return row["created_by"] == user["id"] or user["isAdmin"]
def _shopping_list(conn, row, user: dict) -> dict:
members = [r["user_id"] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (row["id"],))]
return {
"id": row["id"],
"name": row["name"],
"everyone": bool(row["everyone"]),
"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"]),
"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"],))
],
}
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)
everyone = data.get("everyone", True)
if not isinstance(everyone, bool):
raise ValidationError("Ungültige Sichtbarkeit.")
members = data.get("members", [])
if not isinstance(members, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in members):
raise ValidationError("Ungültige Auswahl der Konten.")
members = {m for m in members if m != owner_id}
if members:
known = {r["id"] for r in conn.execute(f"SELECT id FROM users WHERE id IN ({','.join('?' * len(members))})", list(members))}
if known != members:
raise ValidationError("Ein ausgewähltes Konto gibt es nicht mehr.")
return name, everyone, sorted(members) if not everyone else []
def _set_members(conn, list_id: int, members: list) -> None:
conn.execute("DELETE FROM shopping_list_members WHERE list_id = ?", (list_id,))
conn.executemany("INSERT INTO shopping_list_members (list_id, user_id) VALUES (?, ?)", [(list_id, m) for m in members])
def _get_item(conn, item_id: int, user: dict):
row = conn.execute("SELECT * FROM household_items WHERE id = ?", (item_id,)).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden – vielleicht hat ihn gerade jemand anderes geändert.")
_get_list(conn, row["list_id"], user)
return row
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, 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()
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": [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:
return _state(conn, user)
@router.post("/api/haushalt/items", status_code=201)
def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
list_ = data.get("list")
if list_ not in LISTS:
raise ValidationError("Unbekannte Liste.")
name = _clean_name(data.get("name"))
unit = _unit(data.get("unit"))
quantity = _quantity(data.get("quantity", 1), unit)
with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), 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)
@router.patch("/api/haushalt/items/{item_id}")
def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Menge und/oder Einheit ändern bzw. abhaken. Beim Einheitenwechsel wird mit einer bestehenden Zeile zusammengeführt."""
with transaction() as conn:
row = _get_item(conn, item_id, user)
if "checked" in data:
# Abhaken: liegt im Wagen, bleibt aber bis "Einkauf beenden" auf der Einkaufsliste
if not isinstance(data["checked"], bool) or row["list"] != "einkauf":
raise ValidationError("Nur Einträge auf der Einkaufsliste lassen sich abhaken.")
conn.execute(
"UPDATE household_items SET checked = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(int(data["checked"]), now_iso(), user["id"], item_id),
)
if len(data) == 1:
return _state(conn, user)
row = _get_item(conn, item_id, user)
unit = _unit(data.get("unit", row["unit"]))
if "quantity" in data:
quantity = _quantity(data["quantity"], unit)
else:
# Nur Einheit gewechselt: Zahl bleibt, bei "Anzahl" auf ganze Stück aufrunden
quantity = max(1, math.ceil(row["quantity"])) if unit == "stk" else row["quantity"]
other = conn.execute(
"SELECT id FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ? AND id != ?",
(row["list_id"], row["key"], row["list"], unit, item_id),
).fetchone()
if other:
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
_add(conn, row["name"], row["list"], row["list_id"], quantity, unit, user["id"])
else:
conn.execute(
"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)
@router.post("/api/haushalt/items/{item_id}/move")
def move_item(item_id: int, user: dict = Depends(current_user)):
"""Innerhalb einer Liste: Einkauf → Vorrat ("gekauft") bzw. Vorrat → Einkauf ("aufgebraucht"), mit Zusammenführen."""
with transaction() as conn:
row = _get_item(conn, item_id, user)
target = "vorrat" if row["list"] == "einkauf" else "einkauf"
# Aufgebraucht heißt: einmal nachkaufen (1 Stück, 1 kg, 1 l), egal wie viel vorher da war
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)
@router.post("/api/haushalt/listen/{list_id}/einkauf-beenden")
def finish_shopping(list_id: int, user: dict = Depends(current_user)):
"""Alle abgehakten Einträge der Einkaufsliste auf einmal in den Vorrat dieser Liste übernehmen."""
with transaction() as conn:
_get_list(conn, list_id, user)
rows = conn.execute(
"SELECT * FROM household_items WHERE list_id = ? AND list = 'einkauf' AND checked = 1", (list_id,)
).fetchall()
if not rows:
raise ValidationError("Nichts abgehakt – vielleicht hat gerade jemand anderes den Einkauf beendet.")
for row in rows:
conn.execute("DELETE FROM household_items WHERE id = ?", (row["id"],))
_add(conn, row["name"], "vorrat", list_id, row["quantity"], row["unit"], user["id"])
return {**_state(conn, user), "moved": len(rows)}
@router.delete("/api/haushalt/items/{item_id}")
def delete_item(item_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
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)
@router.post("/api/haushalt/listen", status_code=201)
def create_list(data: dict = Body(...), user: dict = Depends(current_user)):
with transaction() as conn:
name, everyone, members = _list_fields(conn, data, user, user["id"])
if conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] >= MAX_LISTS:
raise ValidationError(f"Es gibt schon {MAX_LISTS} Listen. Bitte erst alte löschen.")
position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM shopping_lists").fetchone()[0]
list_id = conn.execute(
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
(name, int(everyone), position, now_iso(), user["id"]),
).lastrowid
_set_members(conn, list_id, members)
return {**_state(conn, user), "created": list_id}
@router.patch("/api/haushalt/listen/{list_id}")
def update_list(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
with transaction() as conn:
row = _get_list(conn, list_id, user)
if not _can_manage(row, user):
raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie ändern.")
name, everyone, members = _list_fields(conn, data, user, row["created_by"])
conn.execute("UPDATE shopping_lists SET name = ?, everyone = ? WHERE id = ?", (name, int(everyone), list_id))
_set_members(conn, list_id, members)
return _state(conn, user)
@router.delete("/api/haushalt/listen/{list_id}")
def delete_list(list_id: int, user: dict = Depends(current_user)):
"""Liste samt Vorrat und Einkauf löschen (ON DELETE CASCADE)."""
with transaction() as conn:
row = _get_list(conn, list_id, user)
if not _can_manage(row, user):
raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie löschen.")
conn.execute("DELETE FROM shopping_lists WHERE id = ?", (list_id,))
return _state(conn, user)
@router.delete("/api/haushalt/produkte")
def delete_product(name: str, user: dict = Depends(current_user)):
"""Vorschlag entfernen (z. B. Tippfehler) – nur aus den Listen, die das Konto sieht. Einträge auf den Listen bleiben."""
with transaction() as conn:
ids = [r["id"] for r in _visible_lists(conn, user)]
cur = conn.execute(
f"DELETE FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})", (_key(name), *ids)
)
if not cur.rowcount:
raise HTTPException(404, "Vorschlag nicht gefunden.")
# Schreibweise nur löschen, wenn keine Liste das Produkt mehr kennt
conn.execute(
"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)
# ---------- 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)