Listen mit eigenem Vorrat und Einkauf, Städte anderer auf der Reise-Karte, Design "Fabrik"
Vorrat & Einkauf: - Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser - Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller und Administratoren - Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb der gewählten Liste - Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause" (für alle sichtbar) Reisen: - Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend - Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war Design "Fabrik" (Industrie-Inventar à la Satisfactory): - Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen, Riffelblech und drehende Zahnräder im Hintergrund - Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden Doku: README verlinkt das zugehörige Repository P42-S.M. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f16c8c73c6
commit
b30acb652a
20 files changed
+1182
-82
No files matched your search
+157
-32
@@ -1,6 +1,8 @@
|
||||
"""Geteilte Vorrats- und Einkaufsliste mit Produktkatalog für einheitliche Namen.
|
||||
"""Vorrat und Einkauf in mehreren Listen sowie Produktkatalog für einheitliche Namen.
|
||||
|
||||
Alle Konten sehen und bearbeiten dieselben Listen. Jedes Produkt steht pro Liste und Einheit
|
||||
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".
|
||||
"""
|
||||
@@ -20,6 +22,8 @@ UNITS = ("stk", "kg", "l")
|
||||
MAX_NAME = 60
|
||||
MAX_QUANTITY = 999
|
||||
MAX_PRODUCTS = 2000
|
||||
MAX_LIST_NAME = 40
|
||||
MAX_LISTS = 50
|
||||
|
||||
|
||||
def _item(row) -> dict:
|
||||
@@ -27,6 +31,7 @@ def _item(row) -> dict:
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"list": row["list"],
|
||||
"listId": row["list_id"],
|
||||
"quantity": _out_quantity(row["quantity"], row["unit"]),
|
||||
"unit": row["unit"],
|
||||
"updatedAt": row["updated_at"],
|
||||
@@ -96,43 +101,124 @@ def _canonical_name(conn, name: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def _add(conn, name: str, list_: str, quantity, unit: str, user_id: int) -> None:
|
||||
"""Produkt auf eine Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht."""
|
||||
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."""
|
||||
conn.execute(
|
||||
"""INSERT INTO household_items (key, name, list, quantity, unit, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (key, list, unit) DO UPDATE SET
|
||||
"""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), ?),
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by""",
|
||||
(_key(name), name, list_, quantity, unit, now_iso(), user_id, MAX_QUANTITY),
|
||||
(_key(name), name, list_, list_id, quantity, unit, now_iso(), user_id, MAX_QUANTITY),
|
||||
)
|
||||
|
||||
|
||||
def _get_item(conn, item_id: int):
|
||||
row = conn.execute("SELECT * FROM household_items WHERE id = ?", (item_id,)).fetchone()
|
||||
# ---------- 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, "Eintrag nicht gefunden – vielleicht hat ihn gerade jemand anderes geändert.")
|
||||
raise HTTPException(404, "Liste nicht gefunden – vielleicht wurde sie gelöscht oder nicht mit dir geteilt.")
|
||||
return row
|
||||
|
||||
|
||||
def _state(conn) -> dict:
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
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 = re.sub(r"\s+", " ", data.get("name")).strip() if isinstance(data.get("name"), str) else ""
|
||||
if not name:
|
||||
raise ValidationError("Name der Liste fehlt.")
|
||||
if len(name) > MAX_LIST_NAME:
|
||||
raise ValidationError(f"Name ist zu lang (max. {MAX_LIST_NAME} Zeichen).")
|
||||
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]
|
||||
items = conn.execute(
|
||||
"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
|
||||
LEFT JOIN users u ON u.id = i.updated_by"""
|
||||
f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
|
||||
LEFT JOIN users u ON u.id = i.updated_by
|
||||
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
|
||||
ids,
|
||||
).fetchall()
|
||||
# Häufig genutzte Produkte zuerst vorschlagen
|
||||
products = conn.execute("SELECT name, uses FROM products").fetchall()
|
||||
users = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
|
||||
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": [{"id": u["id"], "name": u["display_name"]} for u in users],
|
||||
"products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/haushalt")
|
||||
def get_household(_: dict = Depends(current_user)):
|
||||
def get_household(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _state(conn)
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@router.post("/api/haushalt/items", status_code=201)
|
||||
@@ -144,15 +230,16 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
unit = _unit(data.get("unit"))
|
||||
quantity = _quantity(data.get("quantity", 1), unit)
|
||||
with transaction() as conn:
|
||||
_add(conn, _canonical_name(conn, name), list_, quantity, unit, user["id"])
|
||||
return _state(conn)
|
||||
list_id = _get_list(conn, data.get("listId"), user)["id"]
|
||||
_add(conn, _canonical_name(conn, name), list_, list_id, quantity, unit, user["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. Beim Einheitenwechsel wird mit einer bestehenden Zeile zusammengeführt."""
|
||||
with transaction() as conn:
|
||||
row = _get_item(conn, item_id)
|
||||
row = _get_item(conn, item_id, user)
|
||||
unit = _unit(data.get("unit", row["unit"]))
|
||||
if "quantity" in data:
|
||||
quantity = _quantity(data["quantity"], unit)
|
||||
@@ -160,46 +247,84 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
# 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 key = ? AND list = ? AND unit = ? AND id != ?",
|
||||
(row["key"], row["list"], unit, item_id),
|
||||
"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"], quantity, unit, user["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),
|
||||
)
|
||||
return _state(conn)
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@router.post("/api/haushalt/items/{item_id}/move")
|
||||
def move_item(item_id: int, user: dict = Depends(current_user)):
|
||||
"""Einkauf → Vorrat ("gekauft") bzw. Vorrat → Einkauf ("aufgebraucht"), mit Zusammenführen."""
|
||||
"""Innerhalb einer Liste: Einkauf → Vorrat ("gekauft") bzw. Vorrat → Einkauf ("aufgebraucht"), mit Zusammenführen."""
|
||||
with transaction() as conn:
|
||||
row = _get_item(conn, item_id)
|
||||
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, quantity, row["unit"], user["id"])
|
||||
return _state(conn)
|
||||
_add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@router.delete("/api/haushalt/items/{item_id}")
|
||||
def delete_item(item_id: int, _: dict = Depends(current_user)):
|
||||
def delete_item(item_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
_get_item(conn, item_id)
|
||||
_get_item(conn, item_id, user)
|
||||
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
|
||||
return _state(conn)
|
||||
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, _: dict = Depends(current_user)):
|
||||
def delete_product(name: str, user: dict = Depends(current_user)):
|
||||
"""Vorschlag aus dem Katalog entfernen (z. B. Tippfehler). Einträge auf den Listen bleiben."""
|
||||
with transaction() as conn:
|
||||
cur = conn.execute("DELETE FROM products WHERE key = ?", (_key(name),))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Vorschlag nicht gefunden.")
|
||||
return _state(conn)
|
||||
return _state(conn, user)
|
||||
Reference in new issue
Block a user