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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+