import os import json import uuid import random import shutil import time import hmac import hashlib from typing import List, Optional, Dict, Any from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from datetime import datetime, date, timedelta from pydantic import BaseModel, Field from .ingredients import parse_ingredients_block, calculate_portion_matrix from .database import ( init_db, get_all_dishes, add_dish, update_dish, delete_dish, get_stats, query_candidates, query_recipe_candidates, CATEGORIES, EFFORTS, CHAPTERS, CHAPTER_MAP, get_all_recipes, get_recipe, add_recipe, update_recipe, delete_recipe, get_chapter_info_for_dish, mark_recipes_printed, unmark_recipes_printed, toggle_recipe_printed, get_meal_plan, set_meal_plan_slot, delete_meal_plan_slot, clear_meal_plan_week, get_shopping_list, add_shopping_item, add_shopping_items_bulk, toggle_shopping_item, set_shopping_item_checked, delete_shopping_item, delete_checked_shopping_items, clear_shopping_list, get_meal_plan_ingredients, get_shopping_categories, add_shopping_category, update_shopping_category, delete_shopping_category, reorder_shopping_categories, reset_default_shopping_categories, update_shopping_item_category, update_shopping_item, SHELF_TYPES, create_recipe_share, get_recipe_share_by_token, get_recipe_shares_for_recipe, delete_recipe_share, revoke_recipe_share ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(BASE_DIR, "static") TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") # --- Authentifizierung & Privater Modus --- APP_USERNAME = os.getenv("APP_USERNAME", "admin") APP_PASSWORD = os.getenv("APP_PASSWORD", "wtc2026!") SECRET_KEY = os.getenv("SECRET_KEY", "wtc_cookbook_secret_salt_2026_supersecure") COOKIE_NAME = "wtc_auth" PUBLIC_PATHS = {"/login", "/api/login", "/favicon.ico", "/manifest.json", "/sw.js"} def create_auth_token(username: str, days: int = 30) -> str: expiry = int(time.time()) + (days * 86400) msg = f"{username}:{expiry}" sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest() return f"{username}:{expiry}:{sig}" def verify_auth_token(token: Optional[str]) -> bool: if not token: return False parts = token.split(":") if len(parts) != 3: return False user, expiry_str, sig = parts try: expiry = int(expiry_str) except ValueError: return False if time.time() > expiry: return False msg = f"{user}:{expiry}" expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest() return hmac.compare_digest(sig, expected_sig) @asynccontextmanager async def lifespan(app: FastAPI): init_db() os.makedirs(os.path.join(STATIC_DIR, "uploads", "recipes"), exist_ok=True) yield app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=lifespan) # Middleware für Website-weiten Schutz @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path if path.startswith("/static/") or path.startswith("/shared/") or path in PUBLIC_PATHS: return await call_next(request) token = request.cookies.get(COOKIE_NAME) if not verify_auth_token(token): if path.startswith("/api/"): return JSONResponse( status_code=401, content={"status": "unauthorized", "message": "Bitte anmelden."} ) next_target = request.url.path if request.url.query: next_target += f"?{request.url.query}" return RedirectResponse(url=f"/login?next={next_target}", status_code=303) return await call_next(request) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") templates = Jinja2Templates(directory=TEMPLATES_DIR) def strip_step_number_filter(text: str) -> str: if not text: return "" import re return re.sub(r'^(?:Schritt\s*\d+[:.]?|\d+[\.\)\:])\s*', '', str(text).strip(), flags=re.IGNORECASE) templates.env.filters["strip_step_number"] = strip_step_number_filter # Mapping Kochbuch-Kapitel -> Zufallsgenerator-Kategorien CHAPTER_TO_DISH_CAT = { "Suppen & Eintöpfe": "Suppen", "Traditionell & Basics": "Hausmannskost", "Pasta & Nudelgerichte": "Nudeln", "Kartoffeln": "Kartoffeln", "Reis & Getreide": "Reis", "Teig & Gebackenes": "Teigig", "Asiatische Küche": "asiatisch", "Süßspeisen": "Süßspeisen", "Desserts": "Süßspeisen", "Kuchen & Gebäck": "Süßspeisen" } # --- Pydantic Models --- class SlotRollRequest(BaseModel): slot_number: int = Field(..., ge=1, le=5) chapter_number: Optional[int] = None category: Optional[str] = None efforts: Optional[List[str]] = None current_dish_id: Optional[int] = None class MultiRollRequest(BaseModel): slots: List[SlotRollRequest] class SingleRollRequest(BaseModel): slot_number: int = Field(..., ge=1, le=5) chapter_number: Optional[int] = None category: Optional[str] = None efforts: Optional[List[str]] = None current_dish_id: Optional[int] = None other_dish_ids: Optional[List[int]] = Field(default_factory=list) class DishCreate(BaseModel): name: str category: Optional[str] = None effort: str class DishUpdate(BaseModel): name: str category: Optional[str] = None effort: str class MarkPrintedRequest(BaseModel): recipe_ids: List[int] printed_at: Optional[str] = None class UnmarkPrintedRequest(BaseModel): recipe_ids: List[int] class MealPlanSlotRequest(BaseModel): year: int week: int day_of_week: int meal_type: str 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] = "" 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] class ShoppingCategoryCreate(BaseModel): name: str icon: Optional[str] = "🛒" shelf_type: Optional[str] = "other" sort_order: Optional[int] = None class ShoppingCategoryUpdate(BaseModel): name: str icon: Optional[str] = "🛒" shelf_type: Optional[str] = "other" sort_order: Optional[int] = None class ShoppingReorderRequest(BaseModel): category_ids: List[int] class ShoppingItemCategoryUpdate(BaseModel): category: str # ========================================== # PWA MANIFEST & SERVICE WORKER # ========================================== @app.get("/manifest.json") def get_manifest(): manifest_path = os.path.join(STATIC_DIR, "manifest.json") if os.path.exists(manifest_path): return FileResponse(manifest_path, media_type="application/manifest+json") raise HTTPException(status_code=404, detail="Manifest not found") @app.get("/sw.js") def get_service_worker(): sw_path = os.path.join(STATIC_DIR, "sw.js") if os.path.exists(sw_path): return FileResponse(sw_path, media_type="application/javascript", headers={"Service-Worker-Allowed": "/"}) raise HTTPException(status_code=404, detail="Service worker not found") @app.get("/favicon.ico") def get_favicon(): ico_path = os.path.join(STATIC_DIR, "favicon.ico") if os.path.exists(ico_path): return FileResponse(ico_path, media_type="image/x-icon") raise HTTPException(status_code=404, detail="Favicon not found") # ========================================== # AUTH VIEWS # ========================================== @app.get("/login", response_class=HTMLResponse) async def login_get(request: Request, next: str = "/"): token = request.cookies.get(COOKIE_NAME) if verify_auth_token(token): return RedirectResponse(url=next or "/", status_code=303) return templates.TemplateResponse( request=request, name="login.html", context={"next_url": next, "error": None, "username": APP_USERNAME} ) @app.post("/login", response_class=HTMLResponse) async def login_post( request: Request, username: str = Form("admin"), password: str = Form(...), remember: Optional[str] = Form(None), next: str = Form("/") ): valid_user = (username.strip().lower() == APP_USERNAME.lower()) or (username.strip() == "admin") valid_pass = (password == APP_PASSWORD) if valid_user and valid_pass: days = 30 if remember else 1 token = create_auth_token(username.strip(), days=days) target = next if next and not next.startswith("/login") else "/" resp = RedirectResponse(url=target, status_code=303) max_age = days * 86400 resp.set_cookie( key=COOKIE_NAME, value=token, max_age=max_age, expires=max_age, path="/", httponly=True, samesite="lax" ) return resp else: return templates.TemplateResponse( request=request, name="login.html", context={ "next_url": next, "error": "Ungültiges Passwort oder Benutzername.", "username": username }, status_code=400 ) @app.get("/logout") async def logout(): resp = RedirectResponse(url="/login", status_code=303) resp.delete_cookie(key=COOKIE_NAME, path="/") return resp # ========================================== # VIEWS # ========================================== @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() categories = get_shopping_categories() today = date.today() iso_year, iso_week, _ = today.isocalendar() def json_serial(obj): if isinstance(obj, (datetime, date)): return obj.isoformat() return str(obj) items_json = json.dumps(items, default=json_serial) categories_json = json.dumps(categories, default=json_serial) shelf_types_json = json.dumps(SHELF_TYPES, default=json_serial) return templates.TemplateResponse( request=request, name="shopping_list.html", context={ "items": items, "categories": categories, "shelf_types": SHELF_TYPES, "items_json": items_json, "categories_json": categories_json, "shelf_types_json": shelf_types_json, "current_year": iso_year, "current_week": iso_week } ) @app.get("/cookbook", response_class=HTMLResponse) async def cookbook_view( request: Request, chapter: Optional[int] = Query(None) ): """Kochbuch Übersicht mit Kapiteln, Inhaltsverzeichnis und Rezepten""" all_recipes = get_all_recipes() # Berechne Portions-Matrix nur für Waffeln / ausgewählte Rezepte for r in all_recipes: is_matrix = bool(r.get("has_portion_table")) or any(k in (r.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"]) r["has_portion_table"] = is_matrix if is_matrix: parsed = parse_ingredients_block(r.get("ingredients", "")) r["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=r.get("portions") or 4, targets=(2, 4, 6, 8)) # Gruppieren nach Kapitel recipes_by_chapter: Dict[int, List[Dict[str, Any]]] = {} for r in all_recipes: chap_num = r.get("chapter_number", 1) if chap_num not in recipes_by_chapter: recipes_by_chapter[chap_num] = [] recipes_by_chapter[chap_num].append(r) recipe_counts = {c["number"]: len(recipes_by_chapter.get(c["number"], [])) for c in CHAPTERS} if chapter is not None and chapter > 0: chapters_to_display = [c for c in CHAPTERS if c["number"] == chapter] else: chapters_to_display = CHAPTERS # --- Seitenzahlen & Inhaltsverzeichnis (TOC) berechnen --- # Seite 1: Cover # TOC: Bei einzeiligem Inhaltsverzeichnis (1 Spalte) passen ca. 24-28 Einträge (Rezepte + Kapitelheader) ideal auf 1 A4-Seite active_chapters = [c for c in chapters_to_display if recipes_by_chapter.get(c["number"])] toc_pages_raw = [] cur_toc = [] cur_w = 0 MAX_TOC_PAGE_WEIGHT = 26 for c in active_chapters: c_recs = recipes_by_chapter.get(c["number"], []) item_w = len(c_recs) + 2 if cur_w + item_w > MAX_TOC_PAGE_WEIGHT and cur_toc: toc_pages_raw.append(cur_toc) cur_toc = [c] cur_w = item_w else: cur_toc.append(c) cur_w += item_w if cur_toc: toc_pages_raw.append(cur_toc) toc_page_count = len(toc_pages_raw) current_page = 1 + toc_page_count + 1 display_chapters_data = [] for chap in chapters_to_display: chap_copy = dict(chap) chap_recipes = recipes_by_chapter.get(chap["number"], []) if not chap_recipes: display_chapters_data.append(chap_copy) continue chap_copy["page_number"] = current_page current_page += 1 for r in chap_recipes: r["page_number"] = current_page current_page += 1 chap_copy["recipes"] = chap_recipes display_chapters_data.append(chap_copy) # Inhaltsverzeichnis Seiten mit vollständigen Kapitel- & Rezeptdaten display_by_number = {c["number"]: c for c in display_chapters_data} toc_pages = [] for p_idx, p_chaps in enumerate(toc_pages_raw): toc_pages.append({ "page_index": p_idx + 1, "book_page": p_idx + 2, "chapters": [display_by_number[c["number"]] for c in p_chaps if c["number"] in display_by_number] }) toc_p1 = toc_pages[0]["chapters"] if toc_pages else [] toc_p2 = toc_pages[1]["chapters"] if len(toc_pages) > 1 else [] # User Agent Check für Apple iOS user_agent = request.headers.get("user-agent", "").lower() is_ios = any(d in user_agent for d in ["iphone", "ipad", "ipod"]) return templates.TemplateResponse( request=request, name="cookbook.html", context={ "chapters": CHAPTERS, "current_chapter": chapter, "chapters_to_display": display_chapters_data, "recipes_by_chapter": recipes_by_chapter, "recipe_counts": recipe_counts, "total_recipes": len(all_recipes), "toc_pages": toc_pages, "toc_page_count": toc_page_count, "toc_p1": toc_p1, "toc_p2": toc_p2, "is_ios": is_ios } ) @app.get("/cookbook/recipe/{recipe_id}", response_class=HTMLResponse) async def recipe_detail_view(request: Request, recipe_id: int): """Einheitliche Rezeptseite im handgezeichneten Stil""" recipe = get_recipe(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"]) recipe["has_portion_table"] = is_matrix if is_matrix: parsed = parse_ingredients_block(recipe.get("ingredients", "")) recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8)) return templates.TemplateResponse( request=request, name="recipe_detail.html", context={ "recipe": recipe, "chapters": CHAPTERS } ) @app.get("/shared/recipe/{token}", response_class=HTMLResponse) async def shared_recipe_view(request: Request, token: str): """Öffentlich freigegebene Rezeptseite (mit Gültigkeitsprüfung)""" share = get_recipe_share_by_token(token, record_view=True) if not share or share.get("is_expired") or share.get("revoked"): return templates.TemplateResponse( request=request, name="shared_expired.html", context={ "share": share, "reason": "revoked" if (share and share.get("revoked")) else ("expired" if (share and share.get("is_expired")) else "not_found") }, status_code=410 if (share and (share.get("is_expired") or share.get("revoked"))) else 404 ) recipe = get_recipe(share["recipe_id"]) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"]) recipe["has_portion_table"] = is_matrix if is_matrix: parsed = parse_ingredients_block(recipe.get("ingredients", "")) recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8)) return templates.TemplateResponse( request=request, name="recipe_detail.html", context={ "recipe": recipe, "chapters": CHAPTERS, "is_shared": True, "share": share } ) @app.get("/cookbook/new", response_class=HTMLResponse) async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)): """Eingabemaske für neues Rezept""" return templates.TemplateResponse( request=request, name="recipe_form.html", context={ "recipe": None, "chapters": CHAPTERS, "selected_chapter": chapter } ) @app.get("/cookbook/edit/{recipe_id}", response_class=HTMLResponse) async def recipe_edit_view(request: Request, recipe_id: int): """Eingabemaske zum Bearbeiten eines Rezepts""" recipe = get_recipe(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") return templates.TemplateResponse( request=request, name="recipe_form.html", context={ "recipe": recipe, "chapters": CHAPTERS, "selected_chapter": recipe.get("chapter_number") } ) # ========================================== # API: KOCHBUCH (RECIPES) # ========================================== def save_uploaded_image(image_file: UploadFile) -> Optional[str]: if not image_file or not image_file.filename: return None ext = os.path.splitext(image_file.filename)[1].lower() if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif"]: return None upload_dir = os.path.join(STATIC_DIR, "uploads", "recipes") os.makedirs(upload_dir, exist_ok=True) safe_name = f"{uuid.uuid4().hex[:10]}_{os.path.basename(image_file.filename)}" file_path = os.path.join(upload_dir, safe_name) with open(file_path, "wb") as buffer: shutil.copyfileobj(image_file.file, buffer) return f"/static/uploads/recipes/{safe_name}" @app.get("/api/recipes") def api_get_recipes( chapter: Optional[int] = Query(None), category: Optional[str] = Query(None), search: Optional[str] = Query(None), printed: Optional[str] = Query(None) ): return get_all_recipes(chapter_number=chapter, category=category, search=search, printed_status=printed) @app.get("/api/recipes/{recipe_id}") def api_get_recipe(recipe_id: int): recipe = get_recipe(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") recipe["parsed_ingredients"] = parse_ingredients_block(recipe.get("ingredients", "")) return recipe @app.post("/api/recipes") async def api_create_recipe( title: str = Form(...), subtitle: str = Form(""), chapter_number: int = Form(...), effort: str = Form("mittel"), prep_time: str = Form(""), cook_time: str = Form(""), portions: int = Form(4), ingredients: str = Form(...), instructions: str = Form(...), notes: str = Form(""), source: str = Form(""), printed_at: Optional[str] = Form(None), has_portion_table: Optional[int] = Form(0), add_to_dishes: Optional[str] = Form(None), image_file: Optional[UploadFile] = File(None) ): chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None) category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics" image_url = "" if image_file and image_file.filename: image_url = save_uploaded_image(image_file) or "" try: recipe = add_recipe( title=title, subtitle=subtitle, category=category_title, chapter_number=chapter_number, effort=effort, prep_time=prep_time, cook_time=cook_time, portions=portions, ingredients=ingredients, instructions=instructions, notes=notes, source=source, image_url=image_url, printed_at=printed_at if printed_at and printed_at.strip() else None, has_portion_table=1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else 0 ) should_add = bool(add_to_dishes and str(add_to_dishes).strip().lower() in ("true", "on", "1", "yes")) if should_add: dish_cat = CHAPTER_TO_DISH_CAT.get(category_title, "Hausmannskost") dish_effort = effort if effort in EFFORTS else "mittel" try: add_dish(name=title, category=dish_cat, effort=dish_effort) except Exception: pass return {"status": "ok", "recipe": recipe} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.put("/api/recipes/{recipe_id}") async def api_update_recipe( recipe_id: int, title: str = Form(...), subtitle: Optional[str] = Form(None), chapter_number: int = Form(...), effort: str = Form("mittel"), prep_time: str = Form(""), cook_time: str = Form(""), portions: int = Form(4), ingredients: str = Form(...), instructions: str = Form(...), notes: str = Form(""), source: str = Form(""), printed_at: Optional[str] = Form(None), has_portion_table: Optional[int] = Form(None), image_file: Optional[UploadFile] = File(None) ): chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None) category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics" new_image_url = None if image_file and image_file.filename: new_image_url = save_uploaded_image(image_file) try: table_val = 1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else (0 if has_portion_table is not None else None) recipe = update_recipe( recipe_id=recipe_id, title=title, subtitle=subtitle, category=category_title, chapter_number=chapter_number, effort=effort, prep_time=prep_time, cook_time=cook_time, portions=portions, ingredients=ingredients, instructions=instructions, notes=notes, source=source, image_url=new_image_url, printed_at=printed_at if printed_at and printed_at.strip() else None, has_portion_table=table_val ) return {"status": "ok", "recipe": recipe} except KeyError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/recipes/{recipe_id}") def api_delete_recipe(recipe_id: int): success = delete_recipe(recipe_id) if not success: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") return {"status": "ok", "deleted": True} # --- API: Druckstatus & Druckdatum --- @app.post("/api/recipes/mark-printed") def api_mark_recipes_printed(payload: MarkPrintedRequest): count = mark_recipes_printed(payload.recipe_ids, payload.printed_at) return {"status": "ok", "marked_count": count} @app.post("/api/recipes/unmark-printed") def api_unmark_recipes_printed(payload: UnmarkPrintedRequest): count = unmark_recipes_printed(payload.recipe_ids) return {"status": "ok", "unmarked_count": count} @app.post("/api/recipes/{recipe_id}/toggle-printed") def api_toggle_recipe_printed(recipe_id: int): try: updated = toggle_recipe_printed(recipe_id) return {"status": "ok", "recipe": updated} except KeyError as e: raise HTTPException(status_code=404, detail=str(e)) # --- Rezept-Share API --- class CreateShareRequest(BaseModel): expires_in: Optional[str] = "7d" label: Optional[str] = "" def parse_expiry_to_seconds(val: Optional[str]) -> Optional[int]: if not val or val == "unlimited" or val == "0" or val == "never": return None val = val.lower().strip() if val.endswith("h"): return int(float(val[:-1]) * 3600) elif val.endswith("d"): return int(float(val[:-1]) * 86400) elif val.endswith("w"): return int(float(val[:-1]) * 7 * 86400) elif val.endswith("m"): return int(float(val[:-1]) * 30 * 86400) try: sec = int(val) return sec if sec > 0 else None except ValueError: return 7 * 86400 def get_base_url_for_request(request: Request) -> str: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) return f"{scheme}://{host}".rstrip("/") @app.get("/api/recipes/{recipe_id}/shares") def api_get_recipe_shares(request: Request, recipe_id: int): recipe = get_recipe(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") shares = get_recipe_shares_for_recipe(recipe_id) base_url = get_base_url_for_request(request) for s in shares: s["share_url"] = f"{base_url}/shared/recipe/{s['token']}" s["share_path"] = f"/shared/recipe/{s['token']}" if isinstance(s.get("created_at"), (datetime, date)): s["created_at"] = s["created_at"].strftime("%d.%m.%Y %H:%M") return {"status": "ok", "shares": shares} @app.post("/api/recipes/{recipe_id}/shares") def api_create_recipe_share(request: Request, recipe_id: int, payload: CreateShareRequest): recipe = get_recipe(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") sec = parse_expiry_to_seconds(payload.expires_in) share = create_recipe_share(recipe_id, expires_in_seconds=sec, label=payload.label or "") base_url = get_base_url_for_request(request) share["share_url"] = f"{base_url}/shared/recipe/{share['token']}" share["share_path"] = f"/shared/recipe/{share['token']}" return {"status": "ok", "share": share} @app.delete("/api/recipes/{recipe_id}/shares/{share_id}") def api_delete_recipe_share(recipe_id: int, share_id: int): success = delete_recipe_share(share_id, recipe_id) if not success: raise HTTPException(status_code=404, detail="Share-Link nicht gefunden oder bereits gelöscht.") return {"status": "ok", "message": "Share-Link wurde erfolgreich gelöscht."} # ========================================== # API: ZUFALLSGENERATOR & GERICHTE # ========================================== @app.get("/api/categories") def api_categories(): return { "categories": CATEGORIES, "efforts": EFFORTS, "chapters": CHAPTERS } @app.get("/api/stats") def api_stats(): return get_stats() @app.get("/api/dishes") def api_get_dishes( category: Optional[str] = Query(None), effort: Optional[str] = Query(None), search: Optional[str] = Query(None) ): return get_all_dishes(category=category, effort=effort, search=search) @app.post("/api/dishes") def api_create_dish(payload: DishCreate): try: cat = payload.category or "Hausmannskost" dish = add_dish(payload.name, cat, payload.effort) return {"status": "ok", "dish": dish} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.put("/api/dishes/{dish_id}") def api_update_dish(dish_id: int, payload: DishUpdate): try: cat = payload.category or "Hausmannskost" dish = update_dish(dish_id, payload.name, cat, payload.effort) return {"status": "ok", "dish": dish} except KeyError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/dishes/{dish_id}") def api_delete_dish(dish_id: int): success = delete_dish(dish_id) if not success: raise HTTPException(status_code=404, detail="Gericht nicht gefunden.") return {"status": "ok", "deleted": True} def resolve_slot_chapter(slot) -> Optional[int]: if getattr(slot, "chapter_number", None) and slot.chapter_number > 0: return slot.chapter_number if slot.category: s = str(slot.category).strip() if s.isdigit(): return int(s) for c in CHAPTERS: if s.lower() in c["title"].lower(): return c["number"] return None @app.post("/api/roll") def api_roll_dishes(payload: MultiRollRequest): """Rolls recipes directly from the cookbook without duplicates in round.""" slots = payload.slots if not slots: raise HTTPException(status_code=400, detail="Keine Mahlzeiten zur Auslosung übergeben.") if len(slots) > 5: raise HTTPException(status_code=400, detail="Maximal 5 Mahlzeiten können gleichzeitig ausgelost werden.") results = [] chosen_ids = set() slot_candidates = [] for slot in slots: chap_num = resolve_slot_chapter(slot) candidates = query_recipe_candidates(chap_num, slot.efforts) slot_candidates.append({ "slot": slot, "chapter_number": chap_num, "candidates": candidates }) indexed_slots = list(enumerate(slot_candidates)) indexed_slots.sort(key=lambda item: len(item[1]["candidates"])) temp_assignment = {} for original_idx, item in indexed_slots: slot = item["slot"] chap_num = item["chapter_number"] candidates = item["candidates"] available = [c for c in candidates if c["id"] not in chosen_ids] if not available: available = candidates if not available: chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else "" temp_assignment[original_idx] = { "slot_number": slot.slot_number, "recipe": None, "dish": None, "status": "not_found", "chapter_number": chap_num, "chapter_title": chap_title, "message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden." } continue alternatives = [c for c in available if c["id"] != slot.current_dish_id] selection_pool = alternatives if alternatives else available selected = random.choice(selection_pool) chosen_ids.add(selected["id"]) selected_recipe = dict(selected) c_num = selected_recipe.get("chapter_number", 1) chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0] selected_recipe["chapter_title"] = chap["title"] selected_recipe["chapter_icon"] = chap["icon"] selected_recipe["chapter_illustration"] = chap["illustration_url"] selected_recipe["name"] = selected_recipe["title"] selected_recipe["recipe_id"] = selected_recipe["id"] temp_assignment[original_idx] = { "slot_number": slot.slot_number, "dish": selected_recipe, "recipe": selected_recipe, "status": "ok" } for idx in range(len(slots)): results.append(temp_assignment[idx]) return {"status": "ok", "results": results} @app.post("/api/roll-single") def api_roll_single(payload: SingleRollRequest): """Rerolls a single recipe slot directly from cookbook.""" chap_num = resolve_slot_chapter(payload) candidates = query_recipe_candidates(chap_num, payload.efforts) if not candidates: chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else "" return { "status": "not_found", "dish": None, "recipe": None, "chapter_number": chap_num, "chapter_title": chap_title, "message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden." } other_ids = set(payload.other_dish_ids or []) pool = [c for c in candidates if c["id"] not in other_ids] if not pool: pool = candidates alternatives = [c for c in pool if c["id"] != payload.current_dish_id] selection_pool = alternatives if alternatives else pool selected = random.choice(selection_pool) selected_recipe = dict(selected) c_num = selected_recipe.get("chapter_number", 1) chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0] selected_recipe["chapter_title"] = chap["title"] selected_recipe["chapter_icon"] = chap["icon"] selected_recipe["chapter_illustration"] = chap["illustration_url"] selected_recipe["name"] = selected_recipe["title"] selected_recipe["recipe_id"] = selected_recipe["id"] return { "status": "ok", "slot_number": payload.slot_number, "dish": selected_recipe, "recipe": selected_recipe, "is_repetition": (selected["id"] == payload.current_dish_id and len(candidates) > 1) } # ========================================== # API: WOCHENMENÜ-PLAN (MEAL PLANNER) # ========================================== def get_week_dates(year: int, week: int) -> Dict[str, Any]: """Berechnet die Kalenderdaten (Montag bis Sonntag) für eine gegebene KW.""" try: monday = date.fromisocalendar(year, week, 1) except AttributeError: first_day = date(year, 1, 4) monday = first_day + timedelta(days=((week - 1) * 7) - first_day.weekday()) day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] days = [] for i in range(7): cur_date = monday + timedelta(days=i) days.append({ "day_index": i, "name": day_names[i], "date_formatted": cur_date.strftime("%d.%m."), "iso_date": cur_date.strftime("%Y-%m-%d"), "is_today": (cur_date == date.today()) }) sunday = monday + timedelta(days=6) return { "year": year, "week": week, "range_formatted": f"{monday.strftime('%d.%m.')} – {sunday.strftime('%d.%m.%Y')}", "days": days } @app.get("/api/mealplan") def api_get_mealplan(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 week_info = get_week_dates(y, w) slots = get_meal_plan(y, w) # Slot-Map: "0_frueh", "0_mittag", "0_abend" etc. slot_map = {} for s in slots: k = f"{s['day_of_week']}_{s['meal_type']}" slot_map[k] = s # Kompakte Rezeptliste für schnelles Dropdown / Zuweisung all_recs = get_all_recipes() recipes_summary = [ { "id": r["id"], "title": r["title"], "category": r.get("category", ""), "chapter_number": r.get("chapter_number", 1), "effort": r.get("effort", "mittel") } for r in all_recs ] return { "status": "ok", "year": y, "week": w, "current_year": iso_year, "current_week": iso_week, "is_current_week": (y == iso_year and w == iso_week), "range_formatted": week_info["range_formatted"], "days": week_info["days"], "slots": slot_map, "recipes": recipes_summary } @app.post("/api/mealplan/slot") def api_save_mealplan_slot(payload: MealPlanSlotRequest): if payload.day_of_week < 0 or payload.day_of_week > 6: raise HTTPException(status_code=400, detail="Ungültiger Wochentag (0-6).") if payload.meal_type not in ["frueh", "mittag", "kaffee", "abend"]: raise HTTPException(status_code=400, detail="Ungültiger Mahlzeittyp (frueh, mittag, kaffee, abend).") slot = set_meal_plan_slot( year=payload.year, week=payload.week, day_of_week=payload.day_of_week, meal_type=payload.meal_type, recipe_id=payload.recipe_id, custom_text=payload.custom_text ) return {"status": "ok", "slot": slot} @app.delete("/api/mealplan/slot") def api_delete_mealplan_slot( year: int = Query(...), week: int = Query(...), day_of_week: int = Query(...), meal_type: str = Query(...) ): success = delete_meal_plan_slot(year, week, day_of_week, meal_type) return {"status": "ok", "deleted": success} @app.delete("/api/mealplan/week") def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)): count = clear_meal_plan_week(year, week) 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 # ========================================== @app.get("/api/shopping-list") def api_get_shopping_list(): items = get_shopping_list() categories = get_shopping_categories() return {"status": "ok", "items": items, "categories": categories} @app.post("/api/shopping-list") 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( item_name=payload.item_name, amount=payload.amount or "", unit=payload.unit or "", source=payload.source or "", category=payload.category, note=payload.note or "" ) current_items = get_shopping_list() await shopping_ws_manager.broadcast({"action": "items_updated", "items": current_items}) return {"status": "ok", "item": item, "items": current_items} @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") 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) current_items = get_shopping_list() await shopping_ws_manager.broadcast({"action": "items_updated", "items": current_items}) return {"status": "ok", "added_count": count, "items": current_items} @app.post("/api/shopping-list/{item_id}/toggle") async def api_toggle_shopping_item(item_id: int, checked: Optional[bool] = Query(None)): if checked is not None: updated = set_shopping_item_checked(item_id, checked) else: 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") 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") 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") 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}") 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} # ========================================== # SHOPPING CATEGORIES API (SUPERMARKT-BEREICHE) # ========================================== @app.get("/api/shopping-categories") def api_get_shopping_categories(): categories = get_shopping_categories() return {"status": "ok", "categories": categories} @app.post("/api/shopping-categories") async def api_add_shopping_category(payload: ShoppingCategoryCreate): try: cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.shelf_type or "other", 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}") async def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate): cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.shelf_type or "other", 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}") 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") async def api_reorder_shopping_categories(payload: ShoppingReorderRequest): reorder_shopping_categories(payload.category_ids) 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") async def api_reset_default_shopping_categories(preset: str = Query("standard")): cats = reset_default_shopping_categories(preset=preset) await shopping_ws_manager.broadcast({ "action": "categories_updated", "categories": cats, "items": get_shopping_list() }) return {"status": "ok", "categories": cats} # ========================================== # 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 }