857 lines
29 KiB
Python
Executable File
857 lines
29 KiB
Python
Executable File
import os
|
||
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
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
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
|
||
)
|
||
|
||
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"}
|
||
|
||
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
|
||
|
||
|
||
# ==========================================
|
||
# AUTH VIEWS
|
||
# ==========================================
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
async def login_get(request: Request, next: str = "/cookbook"):
|
||
token = request.cookies.get(COOKIE_NAME)
|
||
if verify_auth_token(token):
|
||
return RedirectResponse(url=next or "/cookbook", 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("/cookbook")
|
||
):
|
||
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 "/cookbook"
|
||
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("/")
|
||
async def index_view():
|
||
"""Hauptroute leitet direkt auf das Kochbuch um"""
|
||
return RedirectResponse(url="/cookbook", status_code=302)
|
||
|
||
@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: 1 oder 2 Seiten (wenn > 20 Rezepte vorhanden sind)
|
||
total_display_recipes = len(all_recipes)
|
||
toc_page_count = 2 if total_display_recipes > 20 else (1 if total_display_recipes > 0 else 0)
|
||
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 auf 2 Seiten aufteilen
|
||
toc_p1 = []
|
||
toc_p2 = []
|
||
if toc_page_count == 2:
|
||
accum = 0
|
||
for c in display_chapters_data:
|
||
c_recs = recipes_by_chapter.get(c["number"], [])
|
||
if not c_recs:
|
||
continue
|
||
item_weight = len(c_recs) + 2
|
||
if accum < 28 or not toc_p1:
|
||
toc_p1.append(c)
|
||
accum += item_weight
|
||
else:
|
||
toc_p2.append(c)
|
||
else:
|
||
toc_p1 = [c for c in display_chapters_data if recipes_by_chapter.get(c["number"], [])]
|
||
|
||
# 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_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}
|
||
|