Redesign shopping list app, 2-portal home, real-time WebSocket sync, and edit modals

This commit is contained in:
2026-10-15 20:40:41 +02:00
parent 111966c3ae
commit 55b2823113
4 changed files with 1727 additions and 1230 deletions
+117 -16
View File
@@ -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}