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ü - + - +
Abmelden @@ -281,67 +255,36 @@
- +
-
- - - -
🎲
-
-

Zufallsgenerator

-

Gerichte auslosen

-
-
- - - -
📖
-
-

Kochbuch

-

Rezepte & Druckansicht

-
-
- - - -
📅
-
-

Wochenmenüplan

-

KW {{ current_week }} planen

-
-
- - - -
🛒
-
-

Einkaufsliste

-

- {% if open_shopping_count > 0 %} - {{ open_shopping_count }} offen - {% else %} - Zutaten & Notizen - {% endif %} -

-
-
- - - -
➕
-
-

Rezept hinzufügen

-

Neues Gericht eintragen

-
-
- +
+

What To Cook?

+

Kochen, Planen & Einkaufen

- -
diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html index 3e55d2d..7aa26b1 100644 --- a/app/templates/shopping_list.html +++ b/app/templates/shopping_list.html @@ -2,36 +2,41 @@ - + Einkaufsliste – What To Cook? - + - + - -
- - ⬅️ Zurück - + + + +
+
+ +
+

🛒 Einkaufsliste

+ + ● + Live + +
+ +
+ + + + + + + + + + +
+ + +
+ + ⬅️ Startmenü + + + 📖 Zum Kochbuch + + + + +
+
+ +
-
-

🛒 Einkaufsliste

- - + +
+ +
- -
-
- - -
-
-

Einkaufszettel

-

Nach Supermarkt-Gängen sortiert • Woche {{ current_week }}

-
-
- 0 / {{ items|length }} erledigt -
-
- - -
-
- - -
-
- Bereich: - -
-
- - -
-
- - 📅 Aus Wochenplan übernehmen - -
-
- - -
-
- - -
- -
- - - - - - + + + +
+
+ +
+ +
- + -