diff --git a/DEPLOY.md b/DEPLOY.md
index af13fe2..a481417 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -107,6 +107,6 @@ docker compose exec p42 python -m app.reset_password BENUTZERNAME # Passwort z
- **Monitoring:** Die Prüfungen laufen jetzt aus dem Container. `localhost`/`127.0.0.1` meint dort den Container
selbst, nicht den Server – für Dienste auf dem Server dessen LAN-IP eintragen (z. B. `192.168.0.10:22`).
- **Server-Auslastung:** Das Dashboard hängt zusätzlich im Docker-Netz `p42` (legt `update-docker.sh` an).
- Läuft dort der Agent P42-S.M., im Monitoring „Server-Auslastung (P42-S.M.)“ mit der Adresse
+ Läuft dort der Agent [P42-S.M.](https://github.com/guyg1474/p42.s-m), im Monitoring „Server-Auslastung (P42-S.M.)“ mit der Adresse
`http://p42-sm:9142` und dessen Token eintragen.
- **Zeitzone** ist `Europe/Berlin` (für Uhrzeiten in Monitoring und Logs).
diff --git a/README.md b/README.md
index 09f21ba..f94d48f 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,12 @@ Zwei umschaltbare Designs: Matrix und Blackwall.
- **Backend:** Python (FastAPI) – `backend/`
- **Frontend:** React (Vite, React Router, TipTap-Editor) – `frontend/`
+## Zugehöriges Projekt: P42-S.M.
+
+Die Server-Auslastung im Monitoring liefert der Agent **P42-S.M.** – ein eigenes Repository, das auf
+dem zu überwachenden Server läuft: **https://github.com/guyg1474/p42.s-m**
+Einrichtung dort in der README; danach hier im Monitoring eintragen (siehe [Monitoring](#monitoring)).
+
## Starten
```bash
@@ -63,8 +69,11 @@ Die ganze Seite ist nur nach Anmeldung erreichbar.
- Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an
(dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen).
- Unter **Menü → Mein Konto** ändert jeder seinen Anzeigenamen und sein Passwort.
-- Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte**.
-- Gemeinsam für alle Konten: **Vorrat & Einkauf** und **Kanban**.
+- Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte**
+ (die Städte der anderen lassen sich auf der Karte einblenden).
+- Gemeinsam für alle Konten: **Kanban**.
+- **Vorrat & Einkauf** in Listen (z. B. Zuhause, Urlaub): wer eine Liste anlegt, bestimmt, ob alle
+ Konten sie sehen oder nur ausgewählte.
- **Artikel** sind gemeinsam: alle Konten können sie lesen und bearbeiten; angezeigt wird,
wer ihn geschrieben und wer ihn zuletzt geändert hat. Löschen dürfen nur der Autor und
Administratoren.
@@ -86,7 +95,7 @@ Jedes Konto pflegt seine eigene Serverliste. Geprüft wird alle 30 Sekunden:
- **Webseite (HTTP):** online, wenn die Adresse antwortet (auch mit Anmeldeseite o. Ä.).
- **Port (TCP):** online, wenn der Port Verbindungen annimmt, z. B. `192.168.0.10:22`.
-- **Server-Auslastung (P42-S.M.):** CPU, RAM, Platten, Load und Temperatur direkt auf der Karte.
+- **Server-Auslastung ([P42-S.M.](https://github.com/guyg1474/p42.s-m)):** CPU, RAM, Platten, Load und Temperatur direkt auf der Karte.
Klick auf die Karte öffnet die große Ansicht mit allen Werten (alle 5 s aktualisiert) und dem Verlauf
der letzten 6 h, 24 h oder 2 Tage. Adresse `http://p42-sm:9142` (Agent auf demselben Server), dazu der
Token aus der `.env` von P42-S.M. Der Token wird nur im Backend gespeichert und nie an den Browser gegeben.
@@ -121,10 +130,18 @@ Speichern serverseitig bereinigt.
### Vorrat & Einkauf
-Gemeinsame Liste mit zwei Bereichen: **Vorrat** (was da ist) und **Einkaufen** (was fehlt).
-Beim Eintippen schlägt die Seite bekannte Produkte vor, damit Namen einheitlich bleiben
-("milch" wird automatisch zu "Milch"). Mengen als Anzahl, kg oder Liter.
+Oben wählst du die **Liste**, z. B. „Zuhause“ oder „Urlaub“. Jede Liste hat zwei eigene Bereiche:
+**Vorrat** (was da ist) und **Einkaufen** (was fehlt) – im Urlaub gibt es also einen eigenen Bestand
+und einen eigenen Einkaufszettel.
+- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an. Dabei festlegen, wer sie
+ sieht: **alle Konten** oder **nur ausgewählte**. Der Ersteller sieht seine Liste immer.
+ Name, Sichtbarkeit und Löschen („✎ Liste bearbeiten“) nur für den Ersteller und Administratoren –
+ Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste.
+- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“
+ (für alle sichtbar).
+- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
+ Rezeptabgleich wirken immer innerhalb der gewählten Liste.
- Haken bei „Gekauft“ übernimmt einen Eintrag in den Vorrat; −‑Klick bei der letzten Einheit
im Vorrat setzt ihn auf die Einkaufsliste.
- **Rezept abgleichen:** Link zu einer Rezeptseite (z. B. Chefkoch) oder eine Zutatenliste
@@ -139,6 +156,10 @@ Weltkarte pro Konto: Städte, in denen man war, als Punkte; besuchte Länder aus
Klick auf Land oder Punkt zoomt hin und zeigt unten links Infos (Länderdaten lokal,
Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames).
+Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe,
+einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
+Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
+
### Kanban
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
@@ -154,6 +175,7 @@ Im Menü unter **Design** umschaltbar, gespeichert pro Browser:
|---|---|
| Matrix | rotes Retro-Terminal mit Matrix-Regen (Standard) |
| Blackwall | Cyberpunk-Netrunner: Pink-Rot mit Cyan, leuchtende Datenwand, abgeschrägte Panels |
+| Fabrik | Industrie-Inventar à la Satisfactory: Stahlplatten mit Schrauben, gelb leuchtende Buttons, orange Mengen-Zähler, Warnstreifen, drehende Zahnräder |
„Animation: an/aus“ im Menü stoppt alle Hintergrund-Animationen; bei „Bewegung reduzieren“
im Betriebssystem sind sie automatisch aus. Die Designs sind von den Vorbildern inspiriert,
@@ -186,7 +208,7 @@ backend/
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
app/links.py Schnellzugriff-Links pro Konto
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf)
- app/haushalt.py Vorrat & Einkauf, Produktkatalog, Einheiten
+ app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen
@@ -197,7 +219,7 @@ frontend/
src/api.js API-Aufrufe, Bild-Upload
src/auth.jsx Anmeldestatus
src/styles.css Grund-Styling und Matrix-Design (responsiv, volle Breite)
- src/themes.css Blackwall (überschreibt styles.css)
+ src/themes.css Blackwall und Fabrik (überschreiben styles.css)
src/theme.js, motion.js Design-Auswahl, Animations-Schalter
src/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, …
src/pages/ Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, …
diff --git a/backend/app/db.py b/backend/app/db.py
index 4402034..93afc37 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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")
diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py
index 19f4137..fef5946 100644
--- a/backend/app/haushalt.py
+++ b/backend/app/haushalt.py
@@ -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)
diff --git a/backend/app/reisen.py b/backend/app/reisen.py
index e6a8170..f4a3292 100644
--- a/backend/app/reisen.py
+++ b/backend/app/reisen.py
@@ -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 ----------
diff --git a/backend/app/rezept.py b/backend/app/rezept.py
index c9df328..c8f25cc 100644
--- a/backend/app/rezept.py
+++ b/backend/app/rezept.py
@@ -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)
diff --git a/docs/CREDITS.md b/docs/CREDITS.md
index 7c060c2..7009044 100644
--- a/docs/CREDITS.md
+++ b/docs/CREDITS.md
@@ -8,6 +8,8 @@
| IBM Plex Mono | Matrix (Text) | SIL Open Font License 1.1 |
| Chakra Petch | Blackwall (Text, Beschriftungen) | SIL Open Font License 1.1 |
| Oxanium | Blackwall (Titel) | SIL Open Font License 1.1 |
+| Roboto | Fabrik (Text) | SIL Open Font License 1.1 |
+| Roboto Condensed | Fabrik (Titel, Beschriftungen) | SIL Open Font License 1.1 |
Die Lizenztexte liegen in `frontend/node_modules/@fontsource//LICENSE`.
@@ -25,6 +27,6 @@ Die Quellenangaben stehen auch klein unter der Reise-Karte.
## Designs
-Blackwall ist von Cyberpunk 2077 inspiriert.
+Blackwall ist von Cyberpunk 2077 inspiriert, Fabrik von den Inventar- und Crafting-Menüs aus Satisfactory.
Es werden keine Original-Logos, Abzeichen, Bilder oder geschützten Schriften verwendet; alle
Formen, Farben und Hintergründe sind selbst gezeichnet (CSS, SVG, Canvas).
diff --git a/frontend/index.html b/frontend/index.html
index 5188f4b..5cc5dd6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -11,7 +11,7 @@
(function () {
var t = 'matrix';
try { t = localStorage.getItem('p42.design') || t; } catch (e) {}
- if (['matrix', 'blackwall'].indexOf(t) < 0) t = 'matrix';
+ if (['matrix', 'blackwall', 'fabrik'].indexOf(t) < 0) t = 'matrix';
document.documentElement.setAttribute('data-theme', t);
})();
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4a3ca9b..c67860c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,8 @@
"@fontsource/chakra-petch": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/oxanium": "^5.3.0",
+ "@fontsource/roboto": "^5.3.0",
+ "@fontsource/roboto-condensed": "^5.3.0",
"@fontsource/vt323": "^5.3.0",
"@tiptap/extension-image": "^3.31.4",
"@tiptap/extensions": "^3.31.4",
@@ -80,6 +82,22 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
+ "node_modules/@fontsource/roboto": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz",
+ "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/roboto-condensed": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.3.0.tgz",
+ "integrity": "sha512-Vbh5pesre/M/Jspd10btdKRqLSTe3yn4l6B2tXHGElCeASfA7Pgdpu1mb8wTG8E72J4Xk6FUfqnSe46UN2QAog==",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/vt323": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.3.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7afa777..9860751 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,8 @@
"@fontsource/chakra-petch": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/oxanium": "^5.3.0",
+ "@fontsource/roboto": "^5.3.0",
+ "@fontsource/roboto-condensed": "^5.3.0",
"@fontsource/vt323": "^5.3.0",
"@tiptap/extension-image": "^3.31.4",
"@tiptap/extensions": "^3.31.4",
diff --git a/frontend/src/components/FabrikField.jsx b/frontend/src/components/FabrikField.jsx
new file mode 100644
index 0000000..7895812
--- /dev/null
+++ b/frontend/src/components/FabrikField.jsx
@@ -0,0 +1,28 @@
+// Hintergrund "Fabrik" (Industrie-UI im Stil von Satisfactory): Hallenwand mit Riffelblech-Boden,
+// HUD-Linie oben und zwei langsam drehende Zahnräder. Nur CSS/SVG, ohne Animation steht alles still.
+
+// Zahnrad als Umriss mit Loch (evenodd): Zähne trapezförmig zwischen Fuß- und Kopfkreis
+function gearPath(teeth, outer, inner, hole) {
+ const step = (Math.PI * 2) / teeth;
+ const pts = [];
+ for (let i = 0; i < teeth; i += 1) {
+ const a = i * step;
+ pts.push([inner, a - step * 0.32], [outer, a - step * 0.18], [outer, a + step * 0.18], [inner, a + step * 0.32]);
+ }
+ const ring = pts.map(([r, a], i) => `${i ? 'L' : 'M'}${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`).join('');
+ return `${ring}Z M${hole} 0A${hole} ${hole} 0 1 0 ${-hole} 0A${hole} ${hole} 0 1 0 ${hole} 0Z`;
+}
+
+const BIG = gearPath(14, 48, 40, 15);
+const SMALL = gearPath(10, 48, 38, 18);
+
+export default function FabrikField() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/RecipeCheck.jsx b/frontend/src/components/RecipeCheck.jsx
index a53609d..958ec3d 100644
--- a/frontend/src/components/RecipeCheck.jsx
+++ b/frontend/src/components/RecipeCheck.jsx
@@ -21,9 +21,9 @@ function loadPeople() {
/**
* Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen
- * und Fehlendes auf die Einkaufsliste setzen. find(list, name) sucht in den aktuellen Listen.
+ * und Fehlendes in den Einkauf der gewählten Liste setzen. find(list, name) sucht in Vorrat bzw. Einkauf dieser Liste.
*/
-export default function RecipeCheck({ products, find, onTakeOver, busy }) {
+export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy }) {
const [mode, setMode] = useState('url'); // url | text
const [url, setUrl] = useState('');
const [text, setText] = useState('');
@@ -266,10 +266,10 @@ export default function RecipeCheck({ products, find, onTakeOver, busy }) {
{recipe.invalid
? `Bitte die Menge bei „${recipe.invalid}“ prüfen (Anzahl: ganze Zahl, kg/l: z. B. 0,5).`
: recipe.done != null
- ? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste gesetzt.`
+ ? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste „${shoppingList?.name}“ gesetzt.`
: 'Gramm und ml werden mit kg/l im Vorrat verglichen. EL, Packungen usw. nur auf „vorhanden“.'}