From 0d6d0deb693e3f89d4695c6a48917916d3459325 Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Thu, 15 Oct 2026 13:05:08 +0200 Subject: [PATCH] Feature: Einstiegsseite, Einkaufsliste, Wochenplan-Live-Suche & Zufallsgenerator-Optimierung --- app/database.py | 174 ++++++++- app/main.py | 139 ++++++- app/static/cookbook.css | 194 +++++++++ app/templates/cookbook.html | 650 ++++++++++++++++++++++++++++--- app/templates/home.html | 511 ++++++++++++++++++++++++ app/templates/shopping_list.html | 639 ++++++++++++++++++++++++++++++ 6 files changed, 2240 insertions(+), 67 deletions(-) create mode 100644 app/templates/home.html create mode 100644 app/templates/shopping_list.html diff --git a/app/database.py b/app/database.py index 79847fa..9d97af4 100755 --- a/app/database.py +++ b/app/database.py @@ -193,6 +193,20 @@ def init_db(): ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shopping_list ( + id INT AUTO_INCREMENT PRIMARY KEY, + item_name VARCHAR(255) NOT NULL, + amount VARCHAR(50) DEFAULT '', + unit VARCHAR(50) DEFAULT '', + checked TINYINT(1) NOT NULL DEFAULT 0, + source VARCHAR(255) DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_shop_checked (checked) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """) + cursor.execute("DELETE FROM dishes") else: cursor = conn.cursor() @@ -251,6 +265,19 @@ def init_db(): ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shopping_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_name TEXT NOT NULL, + amount TEXT DEFAULT '', + unit TEXT DEFAULT '', + checked INTEGER NOT NULL DEFAULT 0, + source TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute("DELETE FROM dishes") conn.commit() finally: @@ -461,7 +488,9 @@ def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optio if chapter_number is not None and chapter_number > 0: pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number] else: - pool = list(all_recipes) + # Standard beim Zufallsgenerator: Kategorien Desserts (9), Kuchen & Gebäck (10) und Plätzchen (11) ausschließen + EXCLUDED_SHUFFLE_CHAPTERS = {9, 10, 11} + pool = [r for r in all_recipes if r.get("chapter_number") not in EXCLUDED_SHUFFLE_CHAPTERS] if not pool: return [] @@ -752,3 +781,146 @@ def clear_meal_plan_week(year: int, week: int) -> int: return res.get("rowcount", 0) +# --- Shopping List (Einkaufsliste) --- + +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, created_at, updated_at + FROM shopping_list + ORDER BY checked ASC, id DESC + """ + rows = execute_query(query, fetch="all") + for r in rows: + r["checked"] = bool(r.get("checked")) + return rows + + +def add_shopping_item(item_name: str, amount: str = "", unit: str = "", source: str = "") -> Dict[str, Any]: + """Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu.""" + query = "INSERT INTO shopping_list (item_name, amount, unit, source, checked) VALUES (?, ?, ?, ?, 0)" + res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), source.strip()), 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")) + return row + + +def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int: + """Fügt mehrere Zutaten/Einträge auf einmal zur Einkaufsliste hinzu.""" + if not items: + return 0 + count = 0 + for it in items: + name = (it.get("item_name") or it.get("name") or it.get("raw") or "").strip() + if not name: + continue + amt = str(it.get("amount") or "").strip() + unit = str(it.get("unit") or "").strip() + src = str(it.get("source") or it.get("recipe_title") or "").strip() + query = "INSERT INTO shopping_list (item_name, amount, unit, source, checked) VALUES (?, ?, ?, ?, 0)" + execute_query(query, (name, amt, unit, src), fetch="none", commit=True) + count += 1 + return count + + +def toggle_shopping_item(item_id: int) -> Optional[Dict[str, Any]]: + """Schaltet den Erledigt-Status eines Eintrags um.""" + query = "UPDATE shopping_list SET checked = CASE WHEN checked = 1 THEN 0 ELSE 1 END WHERE id = ?" + execute_query(query, (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")) + return row + + +def delete_shopping_item(item_id: int) -> bool: + """Löscht einen einzelnen Eintrag aus der Einkaufsliste.""" + query = "DELETE FROM shopping_list WHERE id = ?" + res = execute_query(query, (item_id,), fetch="none", commit=True) + return res.get("rowcount", 0) > 0 + + +def delete_checked_shopping_items() -> int: + """Löscht alle bereits abgehakten Einträge aus der Einkaufsliste.""" + query = "DELETE FROM shopping_list WHERE checked = 1" + res = execute_query(query, fetch="none", commit=True) + return res.get("rowcount", 0) + + +def clear_shopping_list() -> int: + """Leert die gesamte Einkaufsliste.""" + query = "DELETE FROM shopping_list" + res = execute_query(query, fetch="none", commit=True) + return res.get("rowcount", 0) + + +def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: + """Gibt alle Zutaten aller Rezepte des Wochenplans strukturiert für die Einkaufsliste zurück.""" + slots = get_meal_plan(year, week) + if not slots: + return [] + + from .ingredients import parse_ingredients_block + + all_recipes = {r["id"]: r for r in get_all_recipes()} + day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + meal_labels = {"frueh": "Früh", "mittag": "Mittag", "kaffee": "Kaffee & Kuchen", "abend": "Abend"} + + recipe_groups = [] + seen_slots = set() + + for s in slots: + r_id = s.get("recipe_id") + if not r_id or r_id not in all_recipes: + continue + + recipe = all_recipes[r_id] + d_idx = s.get("day_of_week", 0) + day_str = day_names[d_idx] if 0 <= d_idx < 7 else f"Tag {d_idx}" + m_type = s.get("meal_type", "") + m_label = meal_labels.get(m_type, m_type.capitalize()) + slot_key = f"{d_idx}_{m_type}_{r_id}" + + if slot_key in seen_slots: + continue + seen_slots.add(slot_key) + + slot_label = f"{day_str} {m_label}" + parsed = parse_ingredients_block(recipe.get("ingredients", "")) + + items = [] + for line in parsed: + if line.get("is_header"): + continue + raw_text = line.get("raw", "").strip() + if not raw_text: + continue + + name = line.get("name") or raw_text + amt = str(line.get("amount") or "") if line.get("amount") is not None else "" + unit = line.get("unit") or "" + + items.append({ + "recipe_id": recipe["id"], + "recipe_title": recipe["title"], + "slot_label": slot_label, + "raw": raw_text, + "name": name, + "amount": amt, + "unit": unit + }) + + if items: + recipe_groups.append({ + "recipe_id": recipe["id"], + "recipe_title": recipe["title"], + "slot_label": slot_label, + "items": items + }) + + return recipe_groups + + + diff --git a/app/main.py b/app/main.py index d6de366..28af26b 100755 --- a/app/main.py +++ b/app/main.py @@ -41,7 +41,15 @@ from .database import ( get_meal_plan, set_meal_plan_slot, delete_meal_plan_slot, - clear_meal_plan_week + clear_meal_plan_week, + get_shopping_list, + add_shopping_item, + add_shopping_items_bulk, + toggle_shopping_item, + delete_shopping_item, + delete_checked_shopping_items, + clear_shopping_list, + get_meal_plan_ingredients ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -172,6 +180,15 @@ class MealPlanSlotRequest(BaseModel): recipe_id: Optional[int] = None custom_text: Optional[str] = None +class ShoppingItemCreate(BaseModel): + item_name: str + amount: Optional[str] = "" + unit: Optional[str] = "" + source: Optional[str] = "" + +class ShoppingBulkCreate(BaseModel): + items: List[ShoppingItemCreate] + # ========================================== # AUTH VIEWS @@ -238,10 +255,43 @@ async def logout(): # VIEWS # ========================================== -@app.get("/") -async def index_view(): - """Hauptroute leitet direkt auf das Kochbuch um""" - return RedirectResponse(url="/cookbook", status_code=302) +@app.get("/", response_class=HTMLResponse) +async def home_view(request: Request): + """Einstiegsseite: Ausgeklapptes Menü / Hub""" + all_recipes = get_all_recipes() + today = date.today() + iso_year, iso_week, _ = today.isocalendar() + shopping_items = get_shopping_list() + open_shopping_count = sum(1 for it in shopping_items if not it.get("checked")) + + return templates.TemplateResponse( + request=request, + name="home.html", + context={ + "recipe_count": len(all_recipes), + "current_year": iso_year, + "current_week": iso_week, + "open_shopping_count": open_shopping_count, + "chapters": CHAPTERS + } + ) + +@app.get("/shopping-list", response_class=HTMLResponse) +@app.get("/groceries", response_class=HTMLResponse) +async def shopping_list_view(request: Request): + """Einkaufsliste Übersicht""" + items = get_shopping_list() + today = date.today() + iso_year, iso_week, _ = today.isocalendar() + return templates.TemplateResponse( + request=request, + name="shopping_list.html", + context={ + "items": items, + "current_year": iso_year, + "current_week": iso_week + } + ) @app.get("/cookbook", response_class=HTMLResponse) async def cookbook_view( @@ -854,3 +904,82 @@ def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)): count = clear_meal_plan_week(year, week) return {"status": "ok", "cleared_count": count} + +# ========================================== +# SHOPPING LIST API +# ========================================== + +@app.get("/api/shopping-list") +def api_get_shopping_list(): + items = get_shopping_list() + return {"status": "ok", "items": items} + + +@app.post("/api/shopping-list") +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( + item_name=payload.item_name, + amount=payload.amount or "", + unit=payload.unit or "", + source=payload.source or "" + ) + return {"status": "ok", "item": item} + + +@app.post("/api/shopping-list/bulk") +def api_add_shopping_bulk(payload: ShoppingBulkCreate): + items_dicts = [it.model_dump() for it in payload.items] + count = add_shopping_items_bulk(items_dicts) + return {"status": "ok", "added_count": count} + + +@app.post("/api/shopping-list/{item_id}/toggle") +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.") + return {"status": "ok", "item": updated} + + +@app.delete("/api/shopping-list/checked") +def api_delete_checked_shopping(): + count = delete_checked_shopping_items() + return {"status": "ok", "deleted_count": count} + + +@app.delete("/api/shopping-list/all") +def api_clear_shopping_list(): + count = clear_shopping_list() + return {"status": "ok", "cleared_count": count} + + +@app.delete("/api/shopping-list/{item_id}") +def api_delete_shopping_item(item_id: int): + success = delete_shopping_item(item_id) + return {"status": "ok", "deleted": success} + + +# ========================================== +# MEAL PLAN INGREDIENTS API +# ========================================== + +@app.get("/api/mealplan/ingredients") +def api_get_mealplan_ingredients( + year: Optional[int] = Query(None), + week: Optional[int] = Query(None) +): + today = date.today() + iso_year, iso_week, _ = today.isocalendar() + y = year or iso_year + w = week or iso_week + groups = get_meal_plan_ingredients(y, w) + return { + "status": "ok", + "year": y, + "week": w, + "groups": groups + } + + diff --git a/app/static/cookbook.css b/app/static/cookbook.css index c982428..5751dfa 100755 --- a/app/static/cookbook.css +++ b/app/static/cookbook.css @@ -2120,4 +2120,198 @@ body.modal-open { /* Hinweis: A4 Druckregeln befinden sich zentral in app/templates/cookbook.html ( + + + + +
+ + 📖 +
+

What To Cook?

+

Privates Kochbuch & Menüplaner

+
+
+ + +
+ + +
+ + + + + +
+ + + diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html new file mode 100644 index 0000000..2fccaa4 --- /dev/null +++ b/app/templates/shopping_list.html @@ -0,0 +1,639 @@ + + + + + + Einkaufsliste – What To Cook? + + + + + + + +
+ + 🛒 +
+

Einkaufsliste

+
+
+ + +
+ + +
+
+ +
+
+

Einkaufszettel

+

Woche {{ current_week }} • Zutaten & Besorgungen

+
+
+ 0 / {{ items|length }} erledigt +
+
+ + +
+ + +
+ + +
+
+ + 📅 Aus Wochenplan übernehmen + +
+
+ + +
+
+ + +
    + {% for item in items %} +
  • + + +
  • + {% endfor %} +
+ + +
+
🎉
+

Alles eingekauft!

+

Deine Einkaufsliste ist momentan leer. Füge oben neue Dinge hinzu oder übernimm Zutaten direkt aus deinem Wochenplan.

+ + 📅 Zutaten aus Wochenplan holen + +
+ +
+
+ +
✓ Gespeichert
+ + + +