Files
wtc.waiswuaschd.de/app/database.py
T

755 lines
31 KiB
Python
Executable File

import os
import sqlite3
from datetime import datetime
from typing import List, Dict, Any, Optional
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
try:
import pymysql
import pymysql.cursors
except ImportError:
pymysql = None
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "wtc.db")
DB_ENGINE = os.getenv("DB_ENGINE", "mariadb").lower()
MARIADB_HOST = os.getenv("MARIADB_HOST", "db.waiswuaschd.de")
MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306"))
MARIADB_USER = os.getenv("MARIADB_USER", "v2_usr_wtc_cookbook")
MARIADB_PASS = os.getenv("MARIADB_PASS", "XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT")
MARIADB_NAME = os.getenv("MARIADB_NAME", "v2_db_wtc_cookbook")
LEGACY_CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
]
# 11 Kochbuch-Kapitel aus dem Benutzerentwurf mit handgezeichneten Doodles & Icons
CHAPTERS = [
{"number": 1, "title": "Suppen & Eintöpfe", "icon": "🍲", "doodle_hint": "Dampfende Suppenschale", "illustration_url": "/static/img/chapters/chapter_1.png"},
{"number": 2, "title": "Traditionell & Basics", "icon": "🥟", "doodle_hint": "Klassiker & Brotzeit", "illustration_url": "/static/img/chapters/chapter_2.png"},
{"number": 3, "title": "Pasta & Nudelgerichte", "icon": "🍝", "doodle_hint": "Spaghetti & Pasta", "illustration_url": "/static/img/chapters/chapter_3.png"},
{"number": 4, "title": "Kartoffeln", "icon": "🥔", "doodle_hint": "Kartoffelauflauf & Klöße", "illustration_url": "/static/img/chapters/chapter_4.png"},
{"number": 5, "title": "Reis & Getreide", "icon": "🍚", "doodle_hint": "Reisschale & Körner", "illustration_url": "/static/img/chapters/chapter_5.png"},
{"number": 6, "title": "Teig & Gebackenes", "icon": "🍕", "doodle_hint": "Pizza, Flammkuchen & Brot", "illustration_url": "/static/img/chapters/chapter_6.png"},
{"number": 7, "title": "Asiatische Küche", "icon": "🥢", "doodle_hint": "Wok, Ramen & Thai-Curry", "illustration_url": "/static/img/chapters/chapter_7.png"},
{"number": 8, "title": "Süßspeisen", "icon": "🥞", "doodle_hint": "Kaiserschmarrn & Knödel", "illustration_url": "/static/img/chapters/chapter_8.png"},
{"number": 9, "title": "Desserts", "icon": "🍰", "doodle_hint": "Tiramisu & Mousse", "illustration_url": "/static/img/chapters/chapter_9.png"},
{"number": 10, "title": "Kuchen & Gebäck", "icon": "🎂", "doodle_hint": "Torten & Feingebäck", "illustration_url": "/static/img/chapters/chapter_10.png"},
{"number": 11, "title": "Plätzchen", "icon": "🍪", "doodle_hint": "Weihnachtsplätzchen & Kekse", "illustration_url": "/static/img/chapters/chapter_11.png"}
]
CATEGORIES = [c["title"] for c in CHAPTERS] + [c for c in LEGACY_CATEGORIES if c not in [ch["title"] for ch in CHAPTERS]]
EFFORTS = ["gering", "mittel", "hoch"]
CHAPTER_MAP = {c["title"]: c["number"] for c in CHAPTERS}
for c in CHAPTERS:
CHAPTER_MAP[str(c["number"])] = c["number"]
def get_connection():
if DB_ENGINE == "mariadb":
if pymysql is None:
raise RuntimeError("PyMySQL ist nicht installiert, wird aber für MariaDB benötigt.")
return pymysql.connect(
host=MARIADB_HOST,
port=MARIADB_PORT,
user=MARIADB_USER,
password=MARIADB_PASS,
database=MARIADB_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True
)
else:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def execute_query(query: str, params: tuple = (), fetch: str = "all", commit: bool = False):
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
# MariaDB uses %s instead of ?
maria_query = query.replace("?", "%s")
with conn.cursor() as cursor:
cursor.execute(maria_query, params)
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = cursor.fetchall()
return rows
elif fetch == "one":
return cursor.fetchone()
else:
return {"lastrowid": last_id, "rowcount": rowcount}
else:
cursor = conn.cursor()
cursor.execute(query, params)
if commit:
conn.commit()
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = [dict(r) for r in cursor.fetchall()]
return rows
elif fetch == "one":
row = cursor.fetchone()
return dict(row) if row else None
else:
return {"lastrowid": last_id, "rowcount": rowcount}
finally:
conn.close()
def init_db():
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL,
effort VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_dish_cat (category),
INDEX idx_dish_eff (effort)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
chapter_number INT NOT NULL,
effort VARCHAR(20) NOT NULL DEFAULT 'mittel',
prep_time VARCHAR(50) DEFAULT '',
cook_time VARCHAR(50) DEFAULT '',
portions INT DEFAULT 4,
ingredients LONGTEXT NOT NULL,
instructions LONGTEXT NOT NULL,
notes TEXT,
source VARCHAR(255) DEFAULT '',
image_url VARCHAR(500) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_recipe_chapter (chapter_number),
INDEX idx_recipe_cat (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
# Migration check for MariaDB
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'source'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN source VARCHAR(255) DEFAULT ''")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'printed_at'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table TINYINT(1) NOT NULL DEFAULT 0 AFTER portions")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
week INT NOT NULL,
day_of_week INT NOT NULL,
meal_type VARCHAR(20) NOT NULL,
recipe_id INT NULL,
custom_text VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_slot (year, week, day_of_week, meal_type),
INDEX idx_plan_week (year, week)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
category TEXT NOT NULL,
effort TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
chapter_number INTEGER NOT NULL,
effort TEXT NOT NULL DEFAULT 'mittel',
prep_time TEXT DEFAULT '',
cook_time TEXT DEFAULT '',
portions INTEGER DEFAULT 4,
has_portion_table INTEGER DEFAULT 0,
ingredients TEXT NOT NULL,
instructions TEXT NOT NULL,
notes TEXT DEFAULT '',
source TEXT DEFAULT '',
image_url TEXT DEFAULT '',
printed_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Migration check for SQLite
cursor.execute("PRAGMA table_info(recipes)")
existing_cols = [c[1] for c in cursor.fetchall()]
if "source" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''")
if "printed_at" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at TIMESTAMP NULL DEFAULT NULL")
if "has_portion_table" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table INTEGER DEFAULT 0")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
week INTEGER NOT NULL,
day_of_week INTEGER NOT NULL,
meal_type TEXT NOT NULL,
recipe_id INTEGER NULL,
custom_text TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (year, week, day_of_week, meal_type)
)
""")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
conn.close()
# --- Dishes (Zufallsgenerator) ---
def get_all_dishes(category: Optional[str] = None, effort: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
query = "SELECT id, name, category, effort, created_at FROM dishes WHERE 1=1"
params = []
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if effort and effort.strip() and effort != "alle":
query += " AND effort = ?"
params.append(effort.strip())
if search and search.strip():
query += " AND name LIKE ?"
params.append(f"%{search.strip()}%")
query += " ORDER BY category ASC, name ASC"
return execute_query(query, tuple(params), fetch="all")
def add_dish(name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'. Gültig sind: {', '.join(CATEGORIES)}")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'. Gültig sind: {', '.join(EFFORTS)}")
try:
res = execute_query(
"INSERT INTO dishes (name, category, effort) VALUES (?, ?, ?)",
(name, category, effort),
fetch="none",
commit=True
)
dish_id = res["lastrowid"]
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def update_dish(dish_id: int, name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'.")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'.")
try:
res = execute_query(
"UPDATE dishes SET name = ?, category = ?, effort = ? WHERE id = ?",
(name, category, effort, dish_id),
fetch="none",
commit=True
)
if res["rowcount"] == 0:
raise KeyError(f"Gericht #{dish_id} nicht gefunden.")
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def delete_dish(dish_id: int) -> bool:
res = execute_query("DELETE FROM dishes WHERE id = ?", (dish_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def clear_all_dishes():
execute_query("DELETE FROM dishes", fetch="none", commit=True)
if DB_ENGINE == "sqlite":
try:
execute_query("DELETE FROM sqlite_sequence WHERE name = 'dishes'", fetch="none", commit=True)
except Exception:
pass
def get_stats() -> Dict[str, Any]:
total_res = execute_query("SELECT COUNT(*) as total FROM dishes", fetch="one")
total = total_res["total"] if total_res else 0
cat_rows = execute_query("SELECT category, COUNT(*) as cnt FROM dishes GROUP BY category", fetch="all")
by_category = {row["category"]: row["cnt"] for row in cat_rows}
eff_rows = execute_query("SELECT effort, COUNT(*) as cnt FROM dishes GROUP BY effort", fetch="all")
by_effort = {row["effort"]: row["cnt"] for row in eff_rows}
recipe_count_res = execute_query("SELECT COUNT(*) as total FROM recipes", fetch="one")
total_recipes = recipe_count_res["total"] if recipe_count_res else 0
return {
"total": total,
"by_category": by_category,
"by_effort": by_effort,
"total_recipes": total_recipes
}
def resolve_category_terms(cat_input: Optional[str]) -> List[str]:
if not cat_input or str(cat_input).strip().lower() in ["", "alle", "beliebig", "none"]:
return []
s = str(cat_input).strip()
chap_num = None
if s.isdigit():
chap_num = int(s)
else:
for c in CHAPTERS:
if s.lower() == c["title"].lower() or s.lower() in c["title"].lower():
chap_num = c["number"]
break
chapter_terms = {
1: ["Suppen", "Eintöpfe", "Suppe", "Eintopf", "Suppen & Eintöpfe", "Chili", "Gulasch"],
2: ["Traditionell & Basics", "Hausmannskost", "Traditionell", "Basics", "Fisch", "Brotzeit", "Klassiker", "Schnitzel", "Rouladen", "Klopse", "Forelle", "Lachs"],
3: ["Pasta & Nudelgerichte", "Nudeln", "Pasta", "Spaghetti", "Penne", "Tagliatelle", "Carbonara", "Lasagne", "Spätzle"],
4: ["Kartoffeln", "Kartoffel", "Gratin", "Pommes", "Püree"],
5: ["Reis & Getreide", "Reis", "Getreide", "Risotto", "Paella"],
6: ["Teig & Gebackenes", "Teigig", "Teig", "Pizza", "Flammkuchen", "Quiche", "Calzone"],
7: ["Asiatische Küche", "asiatisch", "Asiatisch", "Curry", "Pad Thai", "Bami", "Wok", "Ramen", "Sushi"],
8: ["Süßspeisen", "Süßspeise", "Kaiserschmarrn", "Germknödel", "Dampfnudel", "Apfelküchle", "Milchreis"],
9: ["Desserts", "Dessert", "Tiramisu", "Mousse", "Pudding"],
10: ["Kuchen & Gebäck", "Kuchen", "Gebäck", "Torte"],
11: ["Plätzchen", "Kekse", "Keks"]
}
if chap_num and chap_num in chapter_terms:
return chapter_terms[chap_num]
# Legacy category names mapping
legacy_map = {
"nudeln": chapter_terms[3],
"pasta": chapter_terms[3],
"reis": chapter_terms[5],
"kartoffeln": chapter_terms[4],
"teigig": chapter_terms[6],
"suppen": chapter_terms[1],
"eintöpfe": chapter_terms[1],
"eintoepfe": chapter_terms[1],
"hausmannskost": chapter_terms[2],
"asiatisch": chapter_terms[7],
"süßspeisen": chapter_terms[8],
"suessspeisen": chapter_terms[8],
"fisch": ["Fisch", "Forelle", "Lachs", "Kabeljau", "Hausmannskost"]
}
if s.lower() in legacy_map:
return legacy_map[s.lower()]
return [s]
def get_chapter_info_for_dish(dish_category: str = "", dish_name: str = "", recipe_chapter: Optional[int] = None) -> Dict[str, Any]:
if recipe_chapter and 1 <= recipe_chapter <= len(CHAPTERS):
return CHAPTERS[recipe_chapter - 1]
combo = f"{dish_category or ''} {dish_name or ''}".lower()
if any(k in combo for k in ["suppe", "eintopf", "chili", "brühe"]):
return CHAPTERS[0] # 1: Suppen & Eintöpfe
if any(k in combo for k in ["nudel", "pasta", "spaghetti", "penne", "tagliatelle", "carbonara", "bolognese", "spätzle", "lasagne"]):
return CHAPTERS[2] # 3: Pasta & Nudelgerichte
if any(k in combo for k in ["kartoffel", "pommes", "gratin", "püree"]):
return CHAPTERS[3] # 4: Kartoffeln
if any(k in combo for k in ["curry", "pad thai", "bami", "wok", "ramen", "sushi", "asiatisch"]):
return CHAPTERS[6] # 7: Asiatische Küche
if any(k in combo for k in ["reis", "risotto", "paella", "djuvec"]):
return CHAPTERS[4] # 5: Reis & Getreide
if any(k in combo for k in ["pizza", "flammkuchen", "quiche", "calzone", "teig", "brot"]):
return CHAPTERS[5] # 6: Teig & Gebackenes
if any(k in combo for k in ["kuchen", "torte", "muffin", "gebäck"]):
return CHAPTERS[9] # 10: Kuchen & Gebäck
if any(k in combo for k in ["keks", "plätzchen"]):
return CHAPTERS[10] # 11: Plätzchen
if any(k in combo for k in ["tiramisu", "mousse", "pudding", "dessert"]):
return CHAPTERS[8] # 9: Desserts
if any(k in combo for k in ["kaiserschmarrn", "knödel", "apfelküchle", "dampfnudel", "milchreis", "süßspeis"]):
return CHAPTERS[7] # 8: Süßspeisen
return CHAPTERS[1] # 2: Traditionell & Basics
def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Gibt alle Rezepte aus der recipes-Tabelle zurück, die zu den Shuffle-Kriterien passen."""
all_recipes = get_all_recipes()
if not all_recipes:
return []
# 1. Nach Kapitel filtern (wenn spezifiziert)
if chapter_number is not None and chapter_number > 0:
pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number]
else:
pool = list(all_recipes)
if not pool:
return []
# 2. Nach Aufwand filtern (nur, wenn eine Teilmenge gewählt wurde)
if efforts and len(efforts) > 0 and len(efforts) < len(EFFORTS):
clean_efforts = [e.strip().lower() for e in efforts if e.strip() in EFFORTS]
effort_pool = [r for r in pool if (r.get("effort") or "").lower() in clean_efforts]
if effort_pool:
return effort_pool
return pool
def query_candidates(category: Optional[str], efforts: Optional[List[str]]) -> List[Dict[str, Any]]:
"""Fallback-Funktion für alte Dish-Abfragen"""
return query_recipe_candidates()
# --- Recipes (Kochbuch) ---
def format_printed_date(p_at: Any) -> str:
if not p_at:
return ""
if hasattr(p_at, "strftime"):
return p_at.strftime("%d.%m.%Y")
try:
parts = str(p_at).split(" ")[0].split("-")
if len(parts) == 3:
return f"{parts[2]}.{parts[1]}.{parts[0]}"
except Exception:
pass
return str(p_at)
def add_recipe(
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: str = "",
printed_at: Optional[str] = None,
has_portion_table: int = 0
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
if not category:
raise ValueError("Kategorie darf nicht leer sein.")
if chapter_number not in [c["number"] for c in CHAPTERS]:
# Fallback to mapped chapter number if possible
chapter_number = CHAPTER_MAP.get(category, 1)
res = execute_query(
"""
INSERT INTO recipes (title, category, chapter_number, effort, prep_time, cook_time, portions, has_portion_table, ingredients, instructions, notes, source, image_url, printed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(title, category, chapter_number, effort, prep_time, cook_time, portions, int(bool(has_portion_table)), ingredients, instructions, notes, source, image_url, printed_at),
fetch="none",
commit=True
)
recipe_id = res["lastrowid"]
return get_recipe(recipe_id)
def update_recipe(
recipe_id: int,
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: Optional[str] = None,
printed_at: Optional[str] = None,
has_portion_table: Optional[int] = None
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
existing = get_recipe(recipe_id)
if not existing:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0)
if image_url is not None:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, image_url = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, image_url, printed_at, recipe_id)
else:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, printed_at, recipe_id)
res = execute_query(query, params, fetch="none", commit=True)
return get_recipe(recipe_id)
def delete_recipe(recipe_id: int) -> bool:
res = execute_query("DELETE FROM recipes WHERE id = ?", (recipe_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def get_recipe(recipe_id: int) -> Optional[Dict[str, Any]]:
row = execute_query("SELECT * FROM recipes WHERE id = ?", (recipe_id,), fetch="one")
if row:
row["printed_at_formatted"] = format_printed_date(row.get("printed_at"))
row["has_portion_table"] = bool(row.get("has_portion_table", 0))
return row
def get_all_recipes(
chapter_number: Optional[int] = None,
category: Optional[str] = None,
search: Optional[str] = None,
printed_status: Optional[str] = None
) -> List[Dict[str, Any]]:
query = "SELECT * FROM recipes WHERE 1=1"
params = []
if chapter_number is not None and chapter_number > 0:
query += " AND chapter_number = ?"
params.append(chapter_number)
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if search and search.strip():
query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)"
s = f"%{search.strip()}%"
params.extend([s, s, s])
if printed_status == "unprinted":
query += " AND printed_at IS NULL"
elif printed_status == "printed":
query += " AND printed_at IS NOT NULL"
query += " ORDER BY chapter_number ASC, title ASC"
rows = execute_query(query, tuple(params), fetch="all")
for r in rows:
r["printed_at_formatted"] = format_printed_date(r.get("printed_at"))
r["has_portion_table"] = bool(r.get("has_portion_table", 0))
return rows
def mark_recipes_printed(recipe_ids: List[int], print_date: Optional[str] = None) -> int:
"""Markiert die angegebenen Rezepte als gedruckt."""
if not recipe_ids:
return 0
date_val = print_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = ? WHERE id IN ({placeholders})"
params = [date_val] + list(recipe_ids)
res = execute_query(query, tuple(params), fetch="none", commit=True)
return res.get("rowcount", 0)
def unmark_recipes_printed(recipe_ids: List[int]) -> int:
"""Entfernt den Gedruckt-Status von den angegebenen Rezepten."""
if not recipe_ids:
return 0
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = NULL WHERE id IN ({placeholders})"
res = execute_query(query, tuple(recipe_ids), fetch="none", commit=True)
return res.get("rowcount", 0)
def toggle_recipe_printed(recipe_id: int) -> Dict[str, Any]:
"""Schaltet den Druckstatus eines Rezepts um."""
rec = get_recipe(recipe_id)
if not rec:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
if rec.get("printed_at"):
unmark_recipes_printed([recipe_id])
else:
mark_recipes_printed([recipe_id])
return get_recipe(recipe_id)
# ==========================================
# WOCHENPLAN (MEAL PLANNER)
# ==========================================
def get_meal_plan(year: int, week: int) -> List[Dict[str, Any]]:
"""Gibt alle Einträge des Wochenplans für die angegebene KW zurück."""
query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url, r.chapter_number AS recipe_chapter_number,
r.category AS recipe_category
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ?
ORDER BY m.day_of_week ASC,
CASE m.meal_type
WHEN 'frueh' THEN 1
WHEN 'mittag' THEN 2
WHEN 'kaffee' THEN 3
WHEN 'abend' THEN 4
ELSE 5
END ASC
"""
return execute_query(query, (year, week), fetch="all")
def set_meal_plan_slot(
year: int,
week: int,
day_of_week: int,
meal_type: str,
recipe_id: Optional[int] = None,
custom_text: Optional[str] = None
) -> Dict[str, Any]:
"""Setzt oder aktualisiert einen Slot im Wochenplan."""
if DB_ENGINE == "mariadb":
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
recipe_id = VALUES(recipe_id),
custom_text = VALUES(custom_text),
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
else:
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(year, week, day_of_week, meal_type) DO UPDATE SET
recipe_id = excluded.recipe_id,
custom_text = excluded.custom_text,
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
slot_query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ? AND m.day_of_week = ? AND m.meal_type = ?
"""
return execute_query(slot_query, (year, week, day_of_week, meal_type), fetch="one")
def delete_meal_plan_slot(year: int, week: int, day_of_week: int, meal_type: str) -> bool:
"""Löscht einen einzelnen Slot aus dem Wochenplan."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ? AND day_of_week = ? AND meal_type = ?"
res = execute_query(query, (year, week, day_of_week, meal_type), fetch="none", commit=True)
return res.get("rowcount", 0) > 0
def clear_meal_plan_week(year: int, week: int) -> int:
"""Leert den gesamten Wochenplan für eine Kalenderwoche."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ?"
res = execute_query(query, (year, week), fetch="none", commit=True)
return res.get("rowcount", 0)