1308 lines
44 KiB
Python
1308 lines
44 KiB
Python
"""
|
|
Menus API — CRUD for menus, divisions, items + publish/republish + image upload + duplication.
|
|
Prefix: /api/menus/
|
|
"""
|
|
import os
|
|
import uuid
|
|
import logging
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, delete
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from database import get_db
|
|
from auth import get_current_user, require_cap
|
|
from models.user import User
|
|
from models.menu import Menu, MenuDivision, MenuItem
|
|
from models.recipe import Recipe, RecipeSubRecipe
|
|
from api.food_flags import compute_recipe_flags, _collect_recipe_ingredient_ids
|
|
from models.food_flag import FoodFlagCategory, FoodFlag
|
|
from models.ingredient import IngredientFlag, IngredientFlagNone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
|
|
|
|
router = APIRouter()
|
|
|
|
DEFAULT_DIVISIONS = ["Starters", "Mains", "Sides", "Desserts"]
|
|
|
|
|
|
# ── Pydantic Models ──────────────────────────────────────────────────────────
|
|
|
|
class MenuCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
preset_divisions: bool = False
|
|
|
|
class MenuUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class DivisionCreate(BaseModel):
|
|
name: str
|
|
|
|
class DivisionUpdate(BaseModel):
|
|
name: str
|
|
|
|
class ItemPublish(BaseModel):
|
|
recipe_id: int
|
|
division_id: int
|
|
display_name: str
|
|
description: Optional[str] = None
|
|
price: Optional[Decimal] = None
|
|
confirmed_by_name: str
|
|
|
|
class ItemUpdate(BaseModel):
|
|
display_name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
price: Optional[Decimal] = None
|
|
division_id: Optional[int] = None
|
|
|
|
class RepublishRequest(BaseModel):
|
|
confirmed_by_name: str
|
|
|
|
class BulkPublishItem(BaseModel):
|
|
recipe_id: int
|
|
display_name: str
|
|
description: Optional[str] = None
|
|
price: Optional[Decimal] = None
|
|
|
|
class BulkPublishRequest(BaseModel):
|
|
division_id: int
|
|
confirmed_by_name: str
|
|
items: list[BulkPublishItem]
|
|
|
|
class BatchRepublishItem(BaseModel):
|
|
id: int
|
|
confirmed: bool = True
|
|
|
|
class BatchRepublishRequest(BaseModel):
|
|
confirmed_by_name: str
|
|
items: list[BatchRepublishItem]
|
|
|
|
class MenuDuplicate(BaseModel):
|
|
name: str
|
|
|
|
class ReorderRequest(BaseModel):
|
|
ids: list[int]
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
async def _get_menu(menu_id: int, kitchen_id: int, db: AsyncSession) -> Menu:
|
|
result = await db.execute(
|
|
select(Menu).where(Menu.id == menu_id, Menu.kitchen_id == kitchen_id)
|
|
)
|
|
menu = result.scalar_one_or_none()
|
|
if not menu:
|
|
raise HTTPException(404, "Menu not found")
|
|
return menu
|
|
|
|
|
|
async def _collect_sub_recipe_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]:
|
|
"""Recursively collect all sub-recipe IDs in a recipe tree."""
|
|
if depth > 5:
|
|
return []
|
|
ids = [recipe_id]
|
|
sr_result = await db.execute(
|
|
select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
|
|
)
|
|
for (child_id,) in sr_result.fetchall():
|
|
ids.extend(await _collect_sub_recipe_ids(child_id, db, depth + 1))
|
|
return ids
|
|
|
|
|
|
async def _compute_staleness(items: list[MenuItem], db: AsyncSession) -> dict[int, dict]:
|
|
"""Batch compute staleness for menu items. Returns {item_id: {is_stale, stale_reason, is_archived}}."""
|
|
result = {}
|
|
recipe_ids = [item.recipe_id for item in items if item.recipe_id is not None]
|
|
|
|
if not recipe_ids:
|
|
for item in items:
|
|
result[item.id] = {
|
|
"is_stale": False,
|
|
"stale_reason": None,
|
|
"is_archived": item.recipe_id is None,
|
|
}
|
|
return result
|
|
|
|
# Batch load recipe updated_at and is_archived
|
|
recipe_result = await db.execute(
|
|
select(Recipe.id, Recipe.updated_at, Recipe.is_archived).where(Recipe.id.in_(recipe_ids))
|
|
)
|
|
recipe_info = {r.id: (r.updated_at, r.is_archived) for r in recipe_result.fetchall()}
|
|
|
|
# Collect all sub-recipe trees for all menu item recipes
|
|
all_tree_ids: dict[int, list[int]] = {}
|
|
for rid in recipe_ids:
|
|
all_tree_ids[rid] = await _collect_sub_recipe_ids(rid, db)
|
|
|
|
# Batch query max updated_at for all sub-recipes
|
|
all_sub_ids = set()
|
|
for tree in all_tree_ids.values():
|
|
all_sub_ids.update(tree)
|
|
|
|
sub_updated = {}
|
|
if all_sub_ids:
|
|
sub_result = await db.execute(
|
|
select(Recipe.id, Recipe.updated_at).where(Recipe.id.in_(list(all_sub_ids)))
|
|
)
|
|
sub_updated = {r.id: r.updated_at for r in sub_result.fetchall()}
|
|
|
|
for item in items:
|
|
if item.recipe_id is None:
|
|
result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
|
|
continue
|
|
|
|
info = recipe_info.get(item.recipe_id)
|
|
if not info:
|
|
# Recipe was fully deleted (shouldn't happen with SET NULL but handle it)
|
|
result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
|
|
continue
|
|
|
|
recipe_updated, recipe_archived = info
|
|
|
|
if recipe_archived:
|
|
result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
|
|
continue
|
|
|
|
# Check staleness: recipe or any sub-recipe updated after publish
|
|
is_stale = False
|
|
stale_reason = None
|
|
|
|
if recipe_updated and item.published_at and recipe_updated > item.published_at:
|
|
is_stale = True
|
|
stale_reason = "Dish edited after publishing"
|
|
|
|
if not is_stale:
|
|
tree_ids = all_tree_ids.get(item.recipe_id, [])
|
|
for sub_id in tree_ids:
|
|
if sub_id == item.recipe_id:
|
|
continue
|
|
sub_up = sub_updated.get(sub_id)
|
|
if sub_up and item.published_at and sub_up > item.published_at:
|
|
is_stale = True
|
|
stale_reason = "Sub-recipe edited after publishing"
|
|
break
|
|
|
|
result[item.id] = {"is_stale": is_stale, "stale_reason": stale_reason, "is_archived": False}
|
|
|
|
return result
|
|
|
|
|
|
async def _build_snapshot(item: MenuItem, flags: list, user_id: int, confirmed_by_name: str) -> dict:
|
|
"""Build the snapshot JSON blob for a menu item at publish time."""
|
|
return {
|
|
"display_name": item.display_name,
|
|
"description": item.description,
|
|
"price": str(item.price) if item.price is not None else None,
|
|
"confirmed_flags": [
|
|
{
|
|
"id": f.food_flag_id,
|
|
"name": f.flag_name,
|
|
"code": f.flag_code,
|
|
"icon": f.flag_icon,
|
|
"category": f.category_name,
|
|
"propagation": f.propagation_type,
|
|
"excludable": f.excludable_on_request,
|
|
}
|
|
for f in flags if f.is_active
|
|
],
|
|
"confirmed_by_name": confirmed_by_name,
|
|
"confirmed_by_user_id": user_id,
|
|
"published_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
|
|
async def _check_unassessed(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[dict]:
|
|
"""Check for unassessed ingredients in a recipe. Returns list of unassessed if any."""
|
|
all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
|
|
unique_ids = list(set(all_ing_ids))
|
|
if not unique_ids:
|
|
return []
|
|
|
|
req_cat_result = await db.execute(
|
|
select(FoodFlagCategory.id, FoodFlagCategory.name).where(
|
|
FoodFlagCategory.kitchen_id == kitchen_id,
|
|
FoodFlagCategory.required == True,
|
|
)
|
|
)
|
|
required_cats = req_cat_result.all()
|
|
if not required_cats:
|
|
return []
|
|
|
|
cat_flag_map: dict[int, set[int]] = {}
|
|
for cat_id, _ in required_cats:
|
|
rf_result = await db.execute(select(FoodFlag.id).where(FoodFlag.category_id == cat_id))
|
|
cat_flag_map[cat_id] = set(rf_result.scalars().all())
|
|
|
|
from models.ingredient import Ingredient
|
|
unassessed = []
|
|
for ing_id in unique_ids:
|
|
ing_result = await db.execute(select(Ingredient.name).where(Ingredient.id == ing_id))
|
|
name = ing_result.scalar()
|
|
if not name:
|
|
continue
|
|
|
|
none_result = await db.execute(
|
|
select(IngredientFlagNone.category_id).where(IngredientFlagNone.ingredient_id == ing_id)
|
|
)
|
|
none_cat_ids = set(none_result.scalars().all())
|
|
|
|
for cat_id, cat_name in required_cats:
|
|
if cat_id in none_cat_ids:
|
|
continue
|
|
flag_ids = cat_flag_map.get(cat_id, set())
|
|
if not flag_ids:
|
|
continue
|
|
flag_count = await db.execute(
|
|
select(func.count(IngredientFlag.id)).where(
|
|
IngredientFlag.ingredient_id == ing_id,
|
|
IngredientFlag.food_flag_id.in_(flag_ids),
|
|
)
|
|
)
|
|
if flag_count.scalar() == 0:
|
|
unassessed.append({"id": ing_id, "name": name, "category": cat_name})
|
|
break
|
|
|
|
return unassessed
|
|
|
|
|
|
# ── Menu CRUD ────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("")
|
|
async def list_menus(
|
|
search: Optional[str] = Query(None),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List menus with division/item counts."""
|
|
query = (
|
|
select(Menu)
|
|
.options(selectinload(Menu.divisions), selectinload(Menu.items))
|
|
.where(Menu.kitchen_id == user.kitchen_id)
|
|
.order_by(Menu.sort_order, Menu.name)
|
|
)
|
|
result = await db.execute(query)
|
|
menus = result.scalars().all()
|
|
|
|
if search:
|
|
search_lower = search.lower()
|
|
menus = [m for m in menus if search_lower in m.name.lower()]
|
|
|
|
items_list = []
|
|
for m in menus:
|
|
# Compute staleness for this menu's items
|
|
all_items = list(m.items) if m.items else []
|
|
staleness = await _compute_staleness(all_items, db) if all_items else {}
|
|
stale_count = sum(1 for s in staleness.values() if s.get("is_stale"))
|
|
|
|
items_list.append({
|
|
"id": m.id,
|
|
"name": m.name,
|
|
"description": m.description,
|
|
"notes": m.notes,
|
|
"is_active": m.is_active,
|
|
"sort_order": m.sort_order,
|
|
"division_count": len(m.divisions) if m.divisions else 0,
|
|
"item_count": len(all_items),
|
|
"stale_count": stale_count,
|
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
|
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
|
})
|
|
|
|
return items_list
|
|
|
|
|
|
@router.post("")
|
|
async def create_menu(
|
|
body: MenuCreate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new menu, optionally with preset divisions."""
|
|
# Check unique name
|
|
existing = await db.execute(
|
|
select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "A menu with this name already exists")
|
|
|
|
# Get next sort_order
|
|
max_order = await db.execute(
|
|
select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
next_order = (max_order.scalar() or 0) + 1
|
|
|
|
menu = Menu(
|
|
kitchen_id=user.kitchen_id,
|
|
name=body.name,
|
|
description=body.description,
|
|
notes=body.notes,
|
|
sort_order=next_order,
|
|
)
|
|
db.add(menu)
|
|
await db.flush()
|
|
|
|
if body.preset_divisions:
|
|
for i, name in enumerate(DEFAULT_DIVISIONS):
|
|
db.add(MenuDivision(menu_id=menu.id, name=name, sort_order=i))
|
|
|
|
await db.commit()
|
|
await db.refresh(menu)
|
|
|
|
return {
|
|
"id": menu.id,
|
|
"name": menu.name,
|
|
"description": menu.description,
|
|
"notes": menu.notes,
|
|
"is_active": menu.is_active,
|
|
"sort_order": menu.sort_order,
|
|
}
|
|
|
|
|
|
@router.get("/{menu_id}")
|
|
async def get_menu(
|
|
menu_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Full menu detail with divisions, items, staleness, and archived indicators."""
|
|
result = await db.execute(
|
|
select(Menu)
|
|
.options(
|
|
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
|
selectinload(Menu.items),
|
|
)
|
|
.where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
menu = result.scalar_one_or_none()
|
|
if not menu:
|
|
raise HTTPException(404, "Menu not found")
|
|
|
|
# Compute staleness for all items
|
|
all_items = menu.items or []
|
|
staleness = await _compute_staleness(all_items, db)
|
|
|
|
# Build divisions with items
|
|
divisions_data = []
|
|
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
|
div_items = sorted(
|
|
[i for i in all_items if i.division_id == div.id],
|
|
key=lambda i: i.sort_order,
|
|
)
|
|
items_data = []
|
|
for item in div_items:
|
|
stale_info = staleness.get(item.id, {"is_stale": False, "stale_reason": None, "is_archived": False})
|
|
items_data.append({
|
|
"id": item.id,
|
|
"recipe_id": item.recipe_id,
|
|
"display_name": item.display_name,
|
|
"description": item.description,
|
|
"price": str(item.price) if item.price is not None else None,
|
|
"sort_order": item.sort_order,
|
|
"snapshot_json": item.snapshot_json,
|
|
"confirmed_by_name": item.confirmed_by_name,
|
|
"confirmed_by_user_id": item.confirmed_by_user_id,
|
|
"published_at": item.published_at.isoformat() if item.published_at else None,
|
|
"has_image": bool(item.image_path),
|
|
"is_stale": stale_info["is_stale"],
|
|
"stale_reason": stale_info["stale_reason"],
|
|
"is_archived": stale_info["is_archived"],
|
|
})
|
|
divisions_data.append({
|
|
"id": div.id,
|
|
"name": div.name,
|
|
"sort_order": div.sort_order,
|
|
"items": items_data,
|
|
})
|
|
|
|
return {
|
|
"id": menu.id,
|
|
"name": menu.name,
|
|
"description": menu.description,
|
|
"notes": menu.notes,
|
|
"is_active": menu.is_active,
|
|
"sort_order": menu.sort_order,
|
|
"created_at": menu.created_at.isoformat() if menu.created_at else None,
|
|
"updated_at": menu.updated_at.isoformat() if menu.updated_at else None,
|
|
"divisions": divisions_data,
|
|
}
|
|
|
|
|
|
@router.put("/{menu_id}")
|
|
async def update_menu(
|
|
menu_id: int,
|
|
body: MenuUpdate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update menu name, description, notes, or active status."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
if body.name is not None and body.name != menu.name:
|
|
existing = await db.execute(
|
|
select(Menu).where(
|
|
Menu.kitchen_id == user.kitchen_id,
|
|
Menu.name == body.name,
|
|
Menu.id != menu_id,
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "A menu with this name already exists")
|
|
menu.name = body.name
|
|
|
|
if body.description is not None:
|
|
menu.description = body.description
|
|
if body.notes is not None:
|
|
menu.notes = body.notes
|
|
if body.is_active is not None:
|
|
menu.is_active = body.is_active
|
|
|
|
menu.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{menu_id}")
|
|
async def delete_menu(
|
|
menu_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a menu and all its divisions/items."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
# Clean up menu item images from disk
|
|
items_result = await db.execute(
|
|
select(MenuItem.image_path).where(MenuItem.menu_id == menu_id, MenuItem.image_path != None)
|
|
)
|
|
for (path,) in items_result.fetchall():
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
await db.delete(menu)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.patch("/reorder")
|
|
async def reorder_menus(
|
|
body: ReorderRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Reorder menus."""
|
|
for i, mid in enumerate(body.ids):
|
|
result = await db.execute(
|
|
select(Menu).where(Menu.id == mid, Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
m = result.scalar_one_or_none()
|
|
if m:
|
|
m.sort_order = i
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Division CRUD ────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{menu_id}/divisions")
|
|
async def add_division(
|
|
menu_id: int,
|
|
body: DivisionCreate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a division to a menu."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
existing = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.menu_id == menu_id, MenuDivision.name == body.name)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "A division with this name already exists in this menu")
|
|
|
|
max_order = await db.execute(
|
|
select(func.max(MenuDivision.sort_order)).where(MenuDivision.menu_id == menu_id)
|
|
)
|
|
next_order = (max_order.scalar() or 0) + 1
|
|
|
|
div = MenuDivision(menu_id=menu_id, name=body.name, sort_order=next_order)
|
|
db.add(div)
|
|
await db.commit()
|
|
await db.refresh(div)
|
|
|
|
return {"id": div.id, "name": div.name, "sort_order": div.sort_order}
|
|
|
|
|
|
@router.put("/{menu_id}/divisions/{division_id}")
|
|
async def update_division(
|
|
menu_id: int,
|
|
division_id: int,
|
|
body: DivisionUpdate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Rename a division."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
div = result.scalar_one_or_none()
|
|
if not div:
|
|
raise HTTPException(404, "Division not found")
|
|
|
|
# Check unique name
|
|
existing = await db.execute(
|
|
select(MenuDivision).where(
|
|
MenuDivision.menu_id == menu_id,
|
|
MenuDivision.name == body.name,
|
|
MenuDivision.id != division_id,
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "A division with this name already exists in this menu")
|
|
|
|
div.name = body.name
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{menu_id}/divisions/{division_id}")
|
|
async def delete_division(
|
|
menu_id: int,
|
|
division_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a division and its items."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
div = result.scalar_one_or_none()
|
|
if not div:
|
|
raise HTTPException(404, "Division not found")
|
|
|
|
# Clean up item images
|
|
items_result = await db.execute(
|
|
select(MenuItem.image_path).where(MenuItem.division_id == division_id, MenuItem.image_path != None)
|
|
)
|
|
for (path,) in items_result.fetchall():
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
await db.delete(div)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.patch("/{menu_id}/divisions/reorder")
|
|
async def reorder_divisions(
|
|
menu_id: int,
|
|
body: ReorderRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Reorder divisions within a menu."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
for i, div_id in enumerate(body.ids):
|
|
result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == div_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
div = result.scalar_one_or_none()
|
|
if div:
|
|
div.sort_order = i
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Publish / Items ──────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{menu_id}/items")
|
|
async def publish_item(
|
|
menu_id: int,
|
|
body: ItemPublish,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Publish a dish to a menu with allergen confirmation."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
# Verify recipe is a non-archived dish
|
|
recipe_result = await db.execute(
|
|
select(Recipe).where(
|
|
Recipe.id == body.recipe_id,
|
|
Recipe.kitchen_id == user.kitchen_id,
|
|
Recipe.recipe_type == "dish",
|
|
Recipe.is_archived == False,
|
|
)
|
|
)
|
|
recipe = recipe_result.scalar_one_or_none()
|
|
if not recipe:
|
|
raise HTTPException(404, "Dish not found or is archived")
|
|
|
|
# Check not already on this menu
|
|
existing = await db.execute(
|
|
select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == body.recipe_id)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "This dish is already on this menu")
|
|
|
|
# Verify division belongs to this menu
|
|
div_result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
if not div_result.scalar_one_or_none():
|
|
raise HTTPException(400, "Division not found in this menu")
|
|
|
|
# Check for unassessed ingredients
|
|
unassessed = await _check_unassessed(body.recipe_id, user.kitchen_id, db)
|
|
if unassessed:
|
|
raise HTTPException(400, detail={
|
|
"message": "Cannot publish: dish has unassessed ingredients",
|
|
"unassessed_ingredients": unassessed,
|
|
})
|
|
|
|
# Compute flags
|
|
flags = await compute_recipe_flags(body.recipe_id, user.kitchen_id, db)
|
|
|
|
# Get next sort_order
|
|
max_order = await db.execute(
|
|
select(func.max(MenuItem.sort_order)).where(
|
|
MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id
|
|
)
|
|
)
|
|
next_order = (max_order.scalar() or 0) + 1
|
|
|
|
item = MenuItem(
|
|
menu_id=menu_id,
|
|
division_id=body.division_id,
|
|
recipe_id=body.recipe_id,
|
|
display_name=body.display_name,
|
|
description=body.description,
|
|
price=body.price,
|
|
sort_order=next_order,
|
|
confirmed_by_user_id=user.id,
|
|
confirmed_by_name=body.confirmed_by_name,
|
|
published_at=datetime.utcnow(),
|
|
)
|
|
|
|
# Build snapshot
|
|
item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
|
|
|
|
db.add(item)
|
|
await db.commit()
|
|
await db.refresh(item)
|
|
|
|
return {
|
|
"id": item.id,
|
|
"display_name": item.display_name,
|
|
"published_at": item.published_at.isoformat() if item.published_at else None,
|
|
}
|
|
|
|
|
|
@router.post("/{menu_id}/items/bulk")
|
|
async def bulk_publish_items(
|
|
menu_id: int,
|
|
body: BulkPublishRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Bulk publish multiple dishes to a menu."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
# Verify division
|
|
div_result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
if not div_result.scalar_one_or_none():
|
|
raise HTTPException(400, "Division not found in this menu")
|
|
|
|
# Validate all dishes first
|
|
errors = []
|
|
valid_items = []
|
|
for bi in body.items:
|
|
recipe_result = await db.execute(
|
|
select(Recipe).where(
|
|
Recipe.id == bi.recipe_id,
|
|
Recipe.kitchen_id == user.kitchen_id,
|
|
Recipe.recipe_type == "dish",
|
|
Recipe.is_archived == False,
|
|
)
|
|
)
|
|
recipe = recipe_result.scalar_one_or_none()
|
|
if not recipe:
|
|
errors.append({"recipe_id": bi.recipe_id, "error": "Dish not found or is archived"})
|
|
continue
|
|
|
|
existing = await db.execute(
|
|
select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == bi.recipe_id)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
errors.append({"recipe_id": bi.recipe_id, "error": "Already on this menu"})
|
|
continue
|
|
|
|
unassessed = await _check_unassessed(bi.recipe_id, user.kitchen_id, db)
|
|
if unassessed:
|
|
errors.append({
|
|
"recipe_id": bi.recipe_id,
|
|
"error": "Has unassessed ingredients",
|
|
"unassessed_ingredients": unassessed,
|
|
})
|
|
continue
|
|
|
|
valid_items.append((bi, recipe))
|
|
|
|
if errors:
|
|
raise HTTPException(400, detail={"message": "Some dishes failed validation", "errors": errors})
|
|
|
|
# All valid — publish
|
|
max_order = await db.execute(
|
|
select(func.max(MenuItem.sort_order)).where(
|
|
MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id
|
|
)
|
|
)
|
|
next_order = (max_order.scalar() or 0) + 1
|
|
|
|
created = []
|
|
for i, (bi, recipe) in enumerate(valid_items):
|
|
flags = await compute_recipe_flags(bi.recipe_id, user.kitchen_id, db)
|
|
|
|
item = MenuItem(
|
|
menu_id=menu_id,
|
|
division_id=body.division_id,
|
|
recipe_id=bi.recipe_id,
|
|
display_name=bi.display_name,
|
|
description=bi.description,
|
|
price=bi.price,
|
|
sort_order=next_order + i,
|
|
confirmed_by_user_id=user.id,
|
|
confirmed_by_name=body.confirmed_by_name,
|
|
published_at=datetime.utcnow(),
|
|
)
|
|
item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
|
|
db.add(item)
|
|
created.append(bi.recipe_id)
|
|
|
|
await db.commit()
|
|
return {"ok": True, "published_count": len(created), "published_recipe_ids": created}
|
|
|
|
|
|
@router.put("/{menu_id}/items/{item_id}")
|
|
async def update_item(
|
|
menu_id: int,
|
|
item_id: int,
|
|
body: ItemUpdate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Edit a menu item's display info or move to a different division."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Menu item not found")
|
|
|
|
if body.display_name is not None:
|
|
item.display_name = body.display_name
|
|
if body.description is not None:
|
|
item.description = body.description
|
|
if body.price is not None:
|
|
item.price = body.price
|
|
if body.division_id is not None:
|
|
div_result = await db.execute(
|
|
select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
|
|
)
|
|
if not div_result.scalar_one_or_none():
|
|
raise HTTPException(400, "Target division not found in this menu")
|
|
item.division_id = body.division_id
|
|
|
|
# Update snapshot display fields
|
|
if item.snapshot_json:
|
|
snapshot = dict(item.snapshot_json)
|
|
if body.display_name is not None:
|
|
snapshot["display_name"] = body.display_name
|
|
if body.description is not None:
|
|
snapshot["description"] = body.description
|
|
if body.price is not None:
|
|
snapshot["price"] = str(body.price)
|
|
item.snapshot_json = snapshot
|
|
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{menu_id}/items/{item_id}")
|
|
async def delete_item(
|
|
menu_id: int,
|
|
item_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Remove a dish from a menu."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Menu item not found")
|
|
|
|
# Clean up image
|
|
if item.image_path and os.path.exists(item.image_path):
|
|
try:
|
|
os.remove(item.image_path)
|
|
except OSError:
|
|
pass
|
|
|
|
await db.delete(item)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/{menu_id}/items/{item_id}/republish")
|
|
async def republish_item(
|
|
menu_id: int,
|
|
item_id: int,
|
|
body: RepublishRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Republish a menu item — re-confirm allergens and update snapshot."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Menu item not found")
|
|
|
|
if item.recipe_id is None:
|
|
raise HTTPException(400, "Cannot republish: dish has been archived or deleted")
|
|
|
|
# Check unassessed
|
|
unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db)
|
|
if unassessed:
|
|
raise HTTPException(400, detail={
|
|
"message": "Cannot republish: dish has unassessed ingredients",
|
|
"unassessed_ingredients": unassessed,
|
|
})
|
|
|
|
# Recompute flags
|
|
flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db)
|
|
|
|
item.confirmed_by_user_id = user.id
|
|
item.confirmed_by_name = body.confirmed_by_name
|
|
item.published_at = datetime.utcnow()
|
|
item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
|
|
|
|
await db.commit()
|
|
return {"ok": True, "published_at": item.published_at.isoformat()}
|
|
|
|
|
|
@router.patch("/{menu_id}/items/reorder")
|
|
async def reorder_items(
|
|
menu_id: int,
|
|
body: ReorderRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Reorder items within a menu."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
for i, item_id in enumerate(body.ids):
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if item:
|
|
item.sort_order = i
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Batch Republish ──────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{menu_id}/republish-stale")
|
|
async def batch_republish_stale(
|
|
menu_id: int,
|
|
body: BatchRepublishRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Batch republish stale items on a menu."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
results = []
|
|
for bi in body.items:
|
|
if not bi.confirmed:
|
|
results.append({"id": bi.id, "status": "skipped"})
|
|
continue
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == bi.id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
results.append({"id": bi.id, "status": "error", "message": "Item not found"})
|
|
continue
|
|
|
|
if item.recipe_id is None:
|
|
results.append({"id": bi.id, "status": "error", "message": "Dish archived"})
|
|
continue
|
|
|
|
unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db)
|
|
if unassessed:
|
|
results.append({
|
|
"id": bi.id, "status": "blocked",
|
|
"message": "Has unassessed ingredients",
|
|
"unassessed_ingredients": unassessed,
|
|
})
|
|
continue
|
|
|
|
flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db)
|
|
item.confirmed_by_user_id = user.id
|
|
item.confirmed_by_name = body.confirmed_by_name
|
|
item.published_at = datetime.utcnow()
|
|
item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
|
|
results.append({"id": bi.id, "status": "republished"})
|
|
|
|
await db.commit()
|
|
return {"results": results}
|
|
|
|
|
|
# ── Menu Item Image ──────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{menu_id}/items/{item_id}/image")
|
|
async def upload_item_image(
|
|
menu_id: int,
|
|
item_id: int,
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Upload a customer-quality image for a menu item."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Menu item not found")
|
|
|
|
# Remove old image if exists
|
|
if item.image_path and os.path.exists(item.image_path):
|
|
try:
|
|
os.remove(item.image_path)
|
|
except OSError:
|
|
pass
|
|
|
|
# Save new image
|
|
img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "menus")
|
|
os.makedirs(img_dir, exist_ok=True)
|
|
|
|
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
|
filename = f"{uuid.uuid4()}{ext}"
|
|
filepath = os.path.join(img_dir, filename)
|
|
|
|
content = await file.read()
|
|
with open(filepath, "wb") as f:
|
|
f.write(content)
|
|
|
|
item.image_path = filepath
|
|
item.uploaded_by = user.id
|
|
|
|
# Update snapshot with image indicator
|
|
if item.snapshot_json:
|
|
snapshot = dict(item.snapshot_json)
|
|
snapshot["has_image"] = True
|
|
item.snapshot_json = snapshot
|
|
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/{menu_id}/items/{item_id}/image")
|
|
async def serve_item_image(
|
|
menu_id: int,
|
|
item_id: int,
|
|
current_user=Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item or not item.image_path:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
if not os.path.exists(item.image_path):
|
|
raise HTTPException(404, "Image file not found")
|
|
|
|
return FileResponse(item.image_path)
|
|
|
|
|
|
@router.delete("/{menu_id}/items/{item_id}/image")
|
|
async def delete_item_image(
|
|
menu_id: int,
|
|
item_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Remove a menu item's image."""
|
|
await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
result = await db.execute(
|
|
select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Menu item not found")
|
|
|
|
if item.image_path and os.path.exists(item.image_path):
|
|
try:
|
|
os.remove(item.image_path)
|
|
except OSError:
|
|
pass
|
|
|
|
item.image_path = None
|
|
item.uploaded_by = None
|
|
|
|
if item.snapshot_json:
|
|
snapshot = dict(item.snapshot_json)
|
|
snapshot.pop("has_image", None)
|
|
item.snapshot_json = snapshot
|
|
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Menu Duplication ─────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{menu_id}/duplicate")
|
|
async def duplicate_menu(
|
|
menu_id: int,
|
|
body: MenuDuplicate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Clone a menu with new name, copying divisions and items (not images)."""
|
|
menu = await _get_menu(menu_id, user.kitchen_id, db)
|
|
|
|
# Check unique name
|
|
existing = await db.execute(
|
|
select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(400, "A menu with this name already exists")
|
|
|
|
# Load full menu
|
|
result = await db.execute(
|
|
select(Menu)
|
|
.options(selectinload(Menu.divisions).selectinload(MenuDivision.items))
|
|
.where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
source = result.scalar_one_or_none()
|
|
|
|
max_order = await db.execute(
|
|
select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
next_order = (max_order.scalar() or 0) + 1
|
|
|
|
new_menu = Menu(
|
|
kitchen_id=user.kitchen_id,
|
|
name=body.name,
|
|
description=source.description,
|
|
notes=source.notes,
|
|
is_active=False, # Start inactive
|
|
sort_order=next_order,
|
|
)
|
|
db.add(new_menu)
|
|
await db.flush()
|
|
|
|
# Copy divisions and items
|
|
for div in sorted(source.divisions or [], key=lambda d: d.sort_order):
|
|
new_div = MenuDivision(menu_id=new_menu.id, name=div.name, sort_order=div.sort_order)
|
|
db.add(new_div)
|
|
await db.flush()
|
|
|
|
for item in sorted(div.items or [], key=lambda i: i.sort_order):
|
|
new_item = MenuItem(
|
|
menu_id=new_menu.id,
|
|
division_id=new_div.id,
|
|
recipe_id=item.recipe_id,
|
|
display_name=item.display_name,
|
|
description=item.description,
|
|
price=item.price,
|
|
sort_order=item.sort_order,
|
|
snapshot_json=item.snapshot_json,
|
|
confirmed_by_user_id=item.confirmed_by_user_id,
|
|
confirmed_by_name=item.confirmed_by_name,
|
|
published_at=item.published_at,
|
|
# image_path not copied — new menu needs own images
|
|
)
|
|
db.add(new_item)
|
|
|
|
await db.commit()
|
|
return {"id": new_menu.id, "name": new_menu.name}
|
|
|
|
|
|
# ── Flag Matrix (for print) ─────────────────────────────────────────────────
|
|
|
|
@router.get("/{menu_id}/flags")
|
|
async def get_menu_flag_matrix(
|
|
menu_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Consolidated flag matrix for all items on a menu (for print view)."""
|
|
result = await db.execute(
|
|
select(Menu)
|
|
.options(
|
|
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
|
selectinload(Menu.items),
|
|
)
|
|
.where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
|
|
)
|
|
menu = result.scalar_one_or_none()
|
|
if not menu:
|
|
raise HTTPException(404, "Menu not found")
|
|
|
|
# Get all flag categories and flags for this kitchen
|
|
cat_result = await db.execute(
|
|
select(FoodFlagCategory)
|
|
.options(selectinload(FoodFlagCategory.flags))
|
|
.where(FoodFlagCategory.kitchen_id == user.kitchen_id)
|
|
.order_by(FoodFlagCategory.sort_order)
|
|
)
|
|
categories = cat_result.scalars().all()
|
|
|
|
all_flags = []
|
|
for cat in categories:
|
|
for f in sorted(cat.flags, key=lambda x: x.sort_order):
|
|
all_flags.append({
|
|
"id": f.id,
|
|
"name": f.name,
|
|
"code": f.code,
|
|
"icon": f.icon,
|
|
"category_id": cat.id,
|
|
"category_name": cat.name,
|
|
})
|
|
|
|
# Build matrix from snapshots
|
|
divisions_data = []
|
|
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
|
div_items = sorted(
|
|
[i for i in (menu.items or []) if i.division_id == div.id],
|
|
key=lambda i: i.sort_order,
|
|
)
|
|
items_matrix = []
|
|
for item in div_items:
|
|
snapshot_flags = {}
|
|
if item.snapshot_json and "confirmed_flags" in item.snapshot_json:
|
|
snapshot_flags = {f["id"]: True for f in item.snapshot_json["confirmed_flags"]}
|
|
|
|
flag_cells = {}
|
|
for f in all_flags:
|
|
flag_cells[str(f["id"])] = snapshot_flags.get(f["id"], False)
|
|
|
|
items_matrix.append({
|
|
"id": item.id,
|
|
"display_name": item.display_name,
|
|
"flags": flag_cells,
|
|
})
|
|
|
|
divisions_data.append({
|
|
"name": div.name,
|
|
"items": items_matrix,
|
|
})
|
|
|
|
return {
|
|
"menu_name": menu.name,
|
|
"all_flags": all_flags,
|
|
"divisions": divisions_data,
|
|
}
|
|
|
|
|
|
# ── Dish-on-menu lookup (for DishEditor/DishList indicators) ─────────────────
|
|
|
|
@router.get("/dish/{recipe_id}/menus")
|
|
async def get_dish_menus(
|
|
recipe_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get list of menus a dish is published on."""
|
|
result = await db.execute(
|
|
select(MenuItem.menu_id, Menu.name, Menu.is_active)
|
|
.join(Menu, MenuItem.menu_id == Menu.id)
|
|
.where(
|
|
MenuItem.recipe_id == recipe_id,
|
|
Menu.kitchen_id == user.kitchen_id,
|
|
)
|
|
)
|
|
rows = result.fetchall()
|
|
return [
|
|
{"menu_id": r.menu_id, "menu_name": r.name, "is_active": r.is_active}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
|
class GenerateDescriptionRequest(BaseModel):
|
|
recipe_id: int
|
|
recipe_name: str
|
|
ingredients: list[str] = []
|
|
allergen_flags: list[str] = []
|
|
steps_summary: Optional[str] = None
|
|
|
|
|
|
@router.post("/generate-description")
|
|
async def generate_description(
|
|
body: GenerateDescriptionRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Generate a customer-facing menu description using AI."""
|
|
from services.llm_service import generate_menu_description
|
|
|
|
result = await generate_menu_description(
|
|
db=db,
|
|
kitchen_id=user.kitchen_id,
|
|
recipe_name=body.recipe_name,
|
|
ingredients=body.ingredients,
|
|
allergen_flags=body.allergen_flags,
|
|
steps_summary=body.steps_summary,
|
|
)
|
|
|
|
return {
|
|
"llm_status": result["status"],
|
|
"description": result.get("description"),
|
|
"error": result.get("error"),
|
|
}
|