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