""" 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"), }