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:
guyg1474andClaude Opus 5.5 committed 2026-10-15 11:50:46 +02:00
1 parent f16c8c73c6
commit b30acb652a
20 files changed
+1182 -82

No files matched your search

+45 -8
View File
@@ -10,18 +10,20 @@ from .config import DATA_DIR
DB_FILE = DATA_DIR / "p42.db"
# Geteilte Vorrats- und Einkaufsliste; key = Name in casefold() für den Vergleich.
# Pro Produkt, Liste und Einheit eine Zeile ("Mehl 1 kg" und "Mehl 2 Stück" dürfen nebeneinander stehen).
# Vorrat und Einkauf; key = Name in casefold() für den Vergleich.
# Jeder Eintrag gehört zu einer Liste (shopping_lists, z. B. "Zuhause", "Urlaub") mit eigenem Vorrat und Einkauf.
# Pro Liste, Produkt, Bereich und Einheit eine Zeile ("Mehl 1 kg" und "Mehl 2 Stück" dürfen nebeneinander stehen).
HOUSEHOLD_TABLE = """(
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
name TEXT NOT NULL,
list TEXT NOT NULL CHECK (list IN ('vorrat', 'einkauf')),
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
quantity REAL NOT NULL CHECK (quantity > 0),
unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')),
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
UNIQUE (key, list, unit)
UNIQUE (list_id, key, list, unit)
)"""
# Kanban-Projekte (je ein Board), gemeinsam für alle Konten
@@ -115,6 +117,23 @@ CREATE TABLE IF NOT EXISTS monitor_samples (
PRIMARY KEY (monitor_id, ts)
) WITHOUT ROWID;
-- Listen mit je eigenem Vorrat und Einkauf (z. B. "Zuhause", "Urlaub");
-- everyone = 1: alle Konten sehen sie, sonst nur Ersteller und Mitglieder
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
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
);
CREATE TABLE IF NOT EXISTS shopping_list_members (
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, user_id)
);
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE};
-- Produktkatalog für die Vorschläge
@@ -244,15 +263,33 @@ def _import_legacy(conn) -> None:
def _migrate(conn) -> None:
# Vorrat/Einkauf aus der Zeit vor den Einheiten: Tabelle mit Einheit und Kommazahlen neu aufbauen
columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")}
if "unit" in columns:
"""Vorrat/Einkauf aus früheren Versionen (ohne Einheiten, ohne Listen oder Vorrat ohne Liste) neu aufbauen.
Alles, was noch keiner Liste gehört, kommt in die erste Liste bzw. in eine neue Liste "Zuhause",
die alle Konten sehen.
"""
info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")}
if "list_id" in info and info["list_id"]["notnull"]:
return
row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone()
if row:
default = row["id"]
# Die Zwischenversion nannte die gemeinsame Liste "Einkaufsliste"; jetzt enthält sie auch den Vorrat
conn.execute("UPDATE shopping_lists SET name = 'Zuhause' WHERE id = ? AND name = 'Einkaufsliste' AND created_by IS NULL", (default,))
else:
default = conn.execute(
"INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),)
).lastrowid
unit = "unit" if "unit" in info else "'stk'"
list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?"
conn.execute("DROP INDEX IF EXISTS household_vorrat")
conn.execute("DROP INDEX IF EXISTS household_einkauf")
conn.execute("ALTER TABLE household_items RENAME TO household_items_alt")
conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE)
conn.execute(
"""INSERT INTO household_items (id, key, name, list, quantity, unit, updated_at, updated_by)
SELECT id, key, name, list, quantity, 'stk', updated_at, updated_by FROM household_items_alt"""
f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by)
SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""",
(default,),
)
conn.execute("DROP TABLE household_items_alt")
+157 -32
View File
@@ -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)
+21 -1
View File
@@ -1,5 +1,7 @@
"""Reise-Karte pro Konto: besuchte Städte und Länder sowie kurze Infos dazu.
Die Städte der anderen Konten werden mitgeliefert und lassen sich auf der Karte einblenden (nur lesen).
Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames, CC BY 4.0).
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
@@ -40,10 +42,28 @@ def _city(row) -> dict:
}
def _others(conn, user_id: int) -> list:
"""Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto."""
rows = conn.execute(
"""SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id
WHERE c.user_id != ? ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""",
(user_id,),
).fetchall()
people = {}
for r in rows:
person = people.setdefault(r["user_id"], {"id": r["user_id"], "name": r["display_name"], "cities": []})
person["cities"].append(_city(r))
return list(people.values())
def _state(conn, user_id: int) -> dict:
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
return {"cities": [_city(r) for r in cities], "countries": [r["code"] for r in countries]}
return {
"cities": [_city(r) for r in cities],
"countries": [r["code"] for r in countries],
"others": _others(conn, user_id),
}
# ---------- Verwaltung ----------
+4 -3
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import transaction
from .haushalt import _add, _canonical_name, _clean_name, _key, _quantity, _state, _unit
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit
from .links import ValidationError
router = APIRouter()
@@ -342,6 +342,7 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit))
learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)]
with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), user)["id"] if checked else None # Einkauf dieser Liste
for ingredient, product in learned:
product = _canonical_name(conn, product)
if _key(ingredient) == _key(product):
@@ -353,5 +354,5 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
(_key(ingredient), product),
)
for name, quantity, unit in checked:
_add(conn, _canonical_name(conn, name), "einkauf", quantity, unit, user["id"])
return _state(conn)
_add(conn, _canonical_name(conn, name), "einkauf", list_id, quantity, unit, user["id"])
return _state(conn, user)