Files
cookbook/app/main.py
T

1226 lines
41 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
)
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 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)
# 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",
"Plätzchen": "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] = "🛒"
sort_order: Optional[int] = None
class ShoppingCategoryUpdate(BaseModel):
name: str
icon: Optional[str] = "🛒"
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)
return templates.TemplateResponse(
request=request,
name="shopping_list.html",
context={
"items": items,
"categories": categories,
"items_json": items_json,
"categories_json": categories_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("/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(...),
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,
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(...),
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,
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))
# ==========================================
# 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 ""
)
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")
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.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.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():
cats = reset_default_shopping_categories()
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
}