diff --git a/app/database.py b/app/database.py
index 0c03691..f4c297a 100755
--- a/app/database.py
+++ b/app/database.py
@@ -335,6 +335,16 @@ def init_db():
except Exception:
pass
+ try:
+ cursor.execute("""
+ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'shopping_list' AND COLUMN_NAME = 'note'
+ """, (MARIADB_NAME,))
+ if not cursor.fetchone():
+ cursor.execute("ALTER TABLE shopping_list ADD COLUMN note VARCHAR(255) DEFAULT '' AFTER category")
+ except Exception:
+ pass
+
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
@@ -421,6 +431,8 @@ def init_db():
existing_shop_cols = [c[1] for c in cursor.fetchall()]
if "category" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN category TEXT DEFAULT 'Drogerie & Sonstiges'")
+ if "note" not in existing_shop_cols:
+ cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
cursor.execute("DELETE FROM dishes")
conn.commit()
@@ -1039,6 +1051,36 @@ def reset_default_shopping_categories() -> List[Dict[str, Any]]:
return get_shopping_categories()
+def update_shopping_item(
+ item_id: int,
+ item_name: str,
+ amount: str = "",
+ unit: str = "",
+ category: Optional[str] = None,
+ note: str = ""
+) -> Optional[Dict[str, Any]]:
+ """Aktualisiert alle Eigenschaften eines Eintrags in der Einkaufsliste."""
+ cat = (category or "").strip()
+ if not cat:
+ cat = guess_category_for_ingredient(item_name)
+ query = """
+ UPDATE shopping_list
+ SET item_name = ?, amount = ?, unit = ?, category = ?, note = ?
+ WHERE id = ?
+ """
+ execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), cat, (note or "").strip(), item_id), fetch="none", commit=True)
+ row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
+ if row:
+ row["checked"] = bool(row.get("checked"))
+ row["display_source"] = clean_source_label(row.get("source", ""))
+ row["note"] = str(row.get("note") or "").strip()
+ if isinstance(row.get("created_at"), (datetime, date)):
+ row["created_at"] = row["created_at"].isoformat()
+ if isinstance(row.get("updated_at"), (datetime, date)):
+ row["updated_at"] = row["updated_at"].isoformat()
+ return row
+
+
def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[str, Any]]:
"""Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags."""
clean_cat = category.strip()
@@ -1048,6 +1090,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
if row:
row["checked"] = bool(row.get("checked"))
row["display_source"] = clean_source_label(row.get("source", ""))
+ row["note"] = str(row.get("note") or "").strip()
if isinstance(row.get("created_at"), (datetime, date)):
row["created_at"] = row["created_at"].isoformat()
if isinstance(row.get("updated_at"), (datetime, date)):
@@ -1058,7 +1101,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
def get_shopping_list() -> List[Dict[str, Any]]:
"""Gibt alle Einträge der Einkaufsliste zurück, sortiert nach unvollständig/erledigt."""
query = """
- SELECT id, item_name, amount, unit, checked, source, category, created_at, updated_at
+ SELECT id, item_name, amount, unit, checked, source, category, note, created_at, updated_at
FROM shopping_list
ORDER BY checked ASC, id DESC
"""
@@ -1068,6 +1111,7 @@ def get_shopping_list() -> List[Dict[str, Any]]:
if not r.get("category"):
r["category"] = guess_category_for_ingredient(r.get("item_name", ""))
r["display_source"] = clean_source_label(r.get("source", ""))
+ r["note"] = str(r.get("note") or "").strip()
if isinstance(r.get("created_at"), (datetime, date)):
r["created_at"] = r["created_at"].isoformat()
if isinstance(r.get("updated_at"), (datetime, date)):
@@ -1075,19 +1119,28 @@ def get_shopping_list() -> List[Dict[str, Any]]:
return rows
-def add_shopping_item(item_name: str, amount: str = "", unit: str = "", source: str = "", category: Optional[str] = None) -> Dict[str, Any]:
+def add_shopping_item(
+ item_name: str,
+ amount: str = "",
+ unit: str = "",
+ source: str = "",
+ category: Optional[str] = None,
+ note: str = ""
+) -> Dict[str, Any]:
"""Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu."""
cat = (category or "").strip()
if not cat:
cat = guess_category_for_ingredient(item_name)
clean_src = clean_source_label(source)
- query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)"
- res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat), fetch="none", commit=True)
+ clean_note = (note or "").strip()
+ query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)"
+ res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat, clean_note), fetch="none", commit=True)
item_id = res.get("lastrowid")
row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
if row:
row["checked"] = bool(row.get("checked"))
row["display_source"] = clean_source_label(row.get("source", ""))
+ row["note"] = str(row.get("note") or "").strip()
if isinstance(row.get("created_at"), (datetime, date)):
row["created_at"] = row["created_at"].isoformat()
if isinstance(row.get("updated_at"), (datetime, date)):
@@ -1111,8 +1164,9 @@ def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int:
cat = str(it.get("category") or "").strip()
if not cat:
cat = guess_category_for_ingredient(name)
- query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)"
- execute_query(query, (name, amt, unit, src, cat), fetch="none", commit=True)
+ note = str(it.get("note") or "").strip()
+ query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)"
+ execute_query(query, (name, amt, unit, src, cat, note), fetch="none", commit=True)
count += 1
return count
diff --git a/app/main.py b/app/main.py
index 67c1397..dc02445 100755
--- a/app/main.py
+++ b/app/main.py
@@ -9,7 +9,7 @@ import hashlib
from typing import List, Optional, Dict, Any
from contextlib import asynccontextmanager
-from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile
+from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -57,7 +57,8 @@ from .database import (
delete_shopping_category,
reorder_shopping_categories,
reset_default_shopping_categories,
- update_shopping_item_category
+ update_shopping_item_category,
+ update_shopping_item
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -194,6 +195,14 @@ class ShoppingItemCreate(BaseModel):
unit: Optional[str] = ""
source: Optional[str] = ""
category: Optional[str] = None
+ note: Optional[str] = ""
+
+class ShoppingItemUpdate(BaseModel):
+ item_name: str
+ amount: Optional[str] = ""
+ unit: Optional[str] = ""
+ category: Optional[str] = None
+ note: Optional[str] = ""
class ShoppingBulkCreate(BaseModel):
items: List[ShoppingItemCreate]
@@ -945,6 +954,46 @@ def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
return {"status": "ok", "cleared_count": count}
+# ==========================================
+# WEBSOCKET CONNECTION MANAGER FOR LIVE SYNC
+# ==========================================
+
+class ShoppingWebSocketManager:
+ def __init__(self):
+ self.active_connections: List[WebSocket] = []
+
+ async def connect(self, websocket: WebSocket):
+ await websocket.accept()
+ self.active_connections.append(websocket)
+
+ def disconnect(self, websocket: WebSocket):
+ if websocket in self.active_connections:
+ self.active_connections.remove(websocket)
+
+ async def broadcast(self, message: dict):
+ for connection in list(self.active_connections):
+ try:
+ await connection.send_json(message)
+ except Exception:
+ if connection in self.active_connections:
+ self.active_connections.remove(connection)
+
+shopping_ws_manager = ShoppingWebSocketManager()
+
+@app.websocket("/ws/shopping-list")
+async def websocket_shopping_list_endpoint(websocket: WebSocket):
+ await shopping_ws_manager.connect(websocket)
+ try:
+ while True:
+ data = await websocket.receive_text()
+ if data == "ping":
+ await websocket.send_text("pong")
+ except WebSocketDisconnect:
+ shopping_ws_manager.disconnect(websocket)
+ except Exception:
+ shopping_ws_manager.disconnect(websocket)
+
+
# ==========================================
# SHOPPING LIST API
# ==========================================
@@ -957,7 +1006,7 @@ def api_get_shopping_list():
@app.post("/api/shopping-list")
-def api_add_shopping_item(payload: ShoppingItemCreate):
+async def api_add_shopping_item(payload: ShoppingItemCreate):
if not payload.item_name.strip():
raise HTTPException(status_code=400, detail="Name des Eintrags darf nicht leer sein.")
item = add_shopping_item(
@@ -965,49 +1014,75 @@ def api_add_shopping_item(payload: ShoppingItemCreate):
amount=payload.amount or "",
unit=payload.unit or "",
source=payload.source or "",
- category=payload.category
+ category=payload.category,
+ note=payload.note or ""
)
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": item}
+@app.put("/api/shopping-list/{item_id}")
+async def api_update_shopping_item(item_id: int, payload: ShoppingItemUpdate):
+ if not payload.item_name.strip():
+ raise HTTPException(status_code=400, detail="Name darf nicht leer sein.")
+ updated = update_shopping_item(
+ item_id=item_id,
+ item_name=payload.item_name,
+ amount=payload.amount or "",
+ unit=payload.unit or "",
+ category=payload.category,
+ note=payload.note or ""
+ )
+ if not updated:
+ raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
+ return {"status": "ok", "item": updated}
+
+
@app.post("/api/shopping-list/bulk")
-def api_add_shopping_bulk(payload: ShoppingBulkCreate):
+async def api_add_shopping_bulk(payload: ShoppingBulkCreate):
items_dicts = [it.model_dump() for it in payload.items]
count = add_shopping_items_bulk(items_dicts)
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "added_count": count}
@app.post("/api/shopping-list/{item_id}/toggle")
-def api_toggle_shopping_item(item_id: int):
+async def api_toggle_shopping_item(item_id: int):
updated = toggle_shopping_item(item_id)
if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": updated}
@app.post("/api/shopping-list/{item_id}/category")
-def api_update_item_category(item_id: int, payload: ShoppingItemCategoryUpdate):
+async def api_update_item_category(item_id: int, payload: ShoppingItemCategoryUpdate):
updated = update_shopping_item_category(item_id, payload.category)
if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": updated}
@app.delete("/api/shopping-list/checked")
-def api_delete_checked_shopping():
+async def api_delete_checked_shopping():
count = delete_checked_shopping_items()
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted_count": count}
@app.delete("/api/shopping-list/all")
-def api_clear_shopping_list():
+async def api_clear_shopping_list():
count = clear_shopping_list()
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "cleared_count": count}
@app.delete("/api/shopping-list/{item_id}")
-def api_delete_shopping_item(item_id: int):
+async def api_delete_shopping_item(item_id: int):
success = delete_shopping_item(item_id)
+ await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted": success}
@@ -1022,37 +1097,63 @@ def api_get_shopping_categories():
@app.post("/api/shopping-categories")
-def api_add_shopping_category(payload: ShoppingCategoryCreate):
+async def api_add_shopping_category(payload: ShoppingCategoryCreate):
try:
cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.sort_order)
+ await shopping_ws_manager.broadcast({
+ "action": "categories_updated",
+ "categories": get_shopping_categories(),
+ "items": get_shopping_list()
+ })
return {"status": "ok", "category": cat}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/shopping-categories/{cat_id}")
-def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate):
+async def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate):
cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.sort_order)
if not cat:
raise HTTPException(status_code=404, detail="Bereich nicht gefunden.")
+ await shopping_ws_manager.broadcast({
+ "action": "categories_updated",
+ "categories": get_shopping_categories(),
+ "items": get_shopping_list()
+ })
return {"status": "ok", "category": cat}
@app.delete("/api/shopping-categories/{cat_id}")
-def api_delete_shopping_category(cat_id: int):
+async def api_delete_shopping_category(cat_id: int):
success = delete_shopping_category(cat_id)
+ await shopping_ws_manager.broadcast({
+ "action": "categories_updated",
+ "categories": get_shopping_categories(),
+ "items": get_shopping_list()
+ })
return {"status": "ok", "deleted": success}
@app.post("/api/shopping-categories/reorder")
-def api_reorder_shopping_categories(payload: ShoppingReorderRequest):
+async def api_reorder_shopping_categories(payload: ShoppingReorderRequest):
reorder_shopping_categories(payload.category_ids)
- return {"status": "ok", "categories": get_shopping_categories()}
+ cats = get_shopping_categories()
+ await shopping_ws_manager.broadcast({
+ "action": "categories_updated",
+ "categories": cats,
+ "items": get_shopping_list()
+ })
+ return {"status": "ok", "categories": cats}
@app.post("/api/shopping-categories/reset-defaults")
-def api_reset_default_shopping_categories():
+async def api_reset_default_shopping_categories():
cats = reset_default_shopping_categories()
+ await shopping_ws_manager.broadcast({
+ "action": "categories_updated",
+ "categories": cats,
+ "items": get_shopping_list()
+ })
return {"status": "ok", "categories": cats}
diff --git a/app/templates/home.html b/app/templates/home.html
index 4eb7d7f..a408197 100644
--- a/app/templates/home.html
+++ b/app/templates/home.html
@@ -4,17 +4,17 @@
What To Cook? – Menü
-
+
-
+
-
+
-