Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck
This commit is contained in:
Executable
+856
@@ -0,0 +1,856 @@
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user