""" Internal API for in-house apps — API key authentication, dish recipe data, food flag listings, menus. Prefix: /api/external/ """ import os import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Header, Query from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from database import get_db from models.settings import KitchenSettings from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeImage from models.menu import Menu, MenuDivision, MenuItem from models.ingredient import Ingredient from models.food_flag import FoodFlagCategory, FoodFlag from api.food_flags import compute_recipe_flags from api.menus import _compute_staleness logger = logging.getLogger(__name__) router = APIRouter() async def get_kitchen_from_api_key( x_api_key: str = Header(..., alias="X-API-Key"), db: AsyncSession = Depends(get_db), ) -> KitchenSettings: """Authenticate via API key and return the kitchen settings.""" if not x_api_key: raise HTTPException(401, "Missing X-API-Key header") result = await db.execute( select(KitchenSettings).where( KitchenSettings.api_key == x_api_key, KitchenSettings.api_key_enabled == True, ) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(401, "Invalid or disabled API key") return settings @router.get("/recipes/dishes") async def list_dish_recipes( include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), include_costs: bool = Query(False), exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"), kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """List non-archived dish recipes for external consumption.""" query = ( select(Recipe) .options( selectinload(Recipe.menu_section), selectinload(Recipe.images), ) .where( Recipe.kitchen_id == kitchen.kitchen_id, Recipe.recipe_type == "dish", Recipe.is_archived == False, ) .order_by(Recipe.name) ) result = await db.execute(query) recipes = result.scalars().all() exclude_flag_ids = set() if exclude_flags: exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} items = [] for r in recipes: # Get flags flags = await compute_recipe_flags(r.id, kitchen.kitchen_id, db) active_flags = [f for f in flags if f.is_active] # Check exclude filter if exclude_flag_ids: recipe_flag_ids = {f.food_flag_id for f in active_flags} if recipe_flag_ids & exclude_flag_ids: continue flag_data = [ { "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 active_flags ] item = { "id": r.id, "name": r.name, "description": r.description, "menu_section": r.menu_section.name if r.menu_section else None, "prep_time_minutes": r.prep_time_minutes, "cook_time_minutes": r.cook_time_minutes, "flags": flag_data, "images": [ {"id": img.id, "caption": img.caption, "image_type": img.image_type} for img in (r.images or []) ], } # Include costs if requested if include_costs: from api.recipes import _calc_recipe_cost cost_data = await _calc_recipe_cost(r.id, db) item["cost_per_portion"] = cost_data.get("cost_per_portion") item["total_cost"] = cost_data.get("total_cost_recent") # Include ingredients if requested if include_ingredients != "none": item["ingredients"] = await _get_recipe_ingredients(r.id, kitchen.kitchen_id, db, include_ingredients) items.append(item) return items @router.get("/recipes/plated") async def list_plated_recipes_compat( include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), include_costs: bool = Query(False), exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"), kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """Backward-compatible alias for /recipes/dishes.""" return await list_dish_recipes(include_ingredients, include_costs, exclude_flags, kitchen, db) @router.get("/recipes/{recipe_id}") async def get_dish_recipe( recipe_id: int, include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), include_costs: bool = Query(False), kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """Get a single dish recipe for external consumption.""" result = await db.execute( select(Recipe) .options(selectinload(Recipe.menu_section), selectinload(Recipe.images)) .where( Recipe.id == recipe_id, Recipe.kitchen_id == kitchen.kitchen_id, Recipe.recipe_type == "dish", Recipe.is_archived == False, ) ) recipe = result.scalar_one_or_none() if not recipe: raise HTTPException(404, "Recipe not found") flags = await compute_recipe_flags(recipe.id, kitchen.kitchen_id, db) active_flags = [f for f in flags if f.is_active] item = { "id": recipe.id, "name": recipe.name, "description": recipe.description, "menu_section": recipe.menu_section.name if recipe.menu_section else None, "prep_time_minutes": recipe.prep_time_minutes, "cook_time_minutes": recipe.cook_time_minutes, "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 active_flags ], "images": [ {"id": img.id, "caption": img.caption, "image_type": img.image_type} for img in (recipe.images or []) ], } if include_costs: from api.recipes import _calc_recipe_cost cost_data = await _calc_recipe_cost(recipe.id, db) item["cost_per_portion"] = cost_data.get("cost_per_portion") item["total_cost"] = cost_data.get("total_cost_recent") if include_ingredients != "none": item["ingredients"] = await _get_recipe_ingredients(recipe.id, kitchen.kitchen_id, db, include_ingredients) return item @router.get("/food-flags") async def list_food_flags( kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """List all flag categories and flags for external apps.""" result = await db.execute( select(FoodFlagCategory) .options(selectinload(FoodFlagCategory.flags)) .where(FoodFlagCategory.kitchen_id == kitchen.kitchen_id) .order_by(FoodFlagCategory.sort_order) ) categories = result.scalars().all() return [ { "id": cat.id, "name": cat.name, "propagation_type": cat.propagation_type, "flags": [ {"id": f.id, "name": f.name, "code": f.code, "icon": f.icon} for f in sorted(cat.flags, key=lambda x: x.sort_order) ], } for cat in categories ] async def _get_recipe_ingredients(recipe_id: int, kitchen_id: int, db: AsyncSession, mode: str) -> list: """Get ingredient list for external API — flat (consolidated) or nested (sub-recipe breakdown).""" if mode == "flat": # Consolidated list from api.event_orders import _collect_ingredients_for_recipe ing_qtys = await _collect_ingredients_for_recipe(recipe_id, 1.0, db) if not ing_qtys: return [] result = await db.execute( select(Ingredient).where(Ingredient.id.in_(list(ing_qtys.keys()))) ) ingredients = {ing.id: ing for ing in result.scalars().all()} return [ { "ingredient_id": ing_id, "name": ingredients[ing_id].name if ing_id in ingredients else "?", "quantity": round(qty, 3), "unit": ingredients[ing_id].standard_unit if ing_id in ingredients else "", } for ing_id, qty in sorted(ing_qtys.items()) ] elif mode == "nested": # Show sub-recipe breakdown ri_result = await db.execute( select(RecipeIngredient) .options(selectinload(RecipeIngredient.ingredient)) .where(RecipeIngredient.recipe_id == recipe_id) .order_by(RecipeIngredient.sort_order) ) direct = [ { "ingredient_id": ri.ingredient_id, "name": ri.ingredient.name if ri.ingredient else "?", "quantity": float(ri.quantity), "unit": ri.ingredient.standard_unit if ri.ingredient else "", "source": "direct", } for ri in ri_result.scalars().all() ] sr_result = await db.execute( select(RecipeSubRecipe) .options(selectinload(RecipeSubRecipe.child_recipe)) .where(RecipeSubRecipe.parent_recipe_id == recipe_id) ) sub_recipe_ings = [] for sr in sr_result.scalars().all(): child = sr.child_recipe if not child: continue cri_result = await db.execute( select(RecipeIngredient) .options(selectinload(RecipeIngredient.ingredient)) .where(RecipeIngredient.recipe_id == child.id) ) # Use unified output qty for bulk/portioned child recipes child_output_qty = float(child.batch_yield_qty) if child.batch_output_type == "bulk" and child.batch_yield_qty else (child.batch_portions or 1) child_output_unit = child.batch_yield_unit if child.batch_output_type == "bulk" and child.batch_yield_unit else "portion" # Convert portions_needed to child output unit if different unit was used needed = float(sr.portions_needed) needed_unit = sr.portions_needed_unit or child_output_unit if needed_unit != child_output_unit: _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} if needed_unit in _bases and child_output_unit in _bases: needed = needed * _bases[needed_unit] / _bases[child_output_unit] scale = needed / child_output_qty for cri in cri_result.scalars().all(): sub_recipe_ings.append({ "ingredient_id": cri.ingredient_id, "name": cri.ingredient.name if cri.ingredient else "?", "quantity": round(float(cri.quantity) * scale, 3), "unit": cri.ingredient.standard_unit if cri.ingredient else "", "source": f"sub-recipe: {child.name}", }) return direct + sub_recipe_ings return [] # ── Menu Endpoints ─────────────────────────────────────────────────────────── @router.get("/menus") async def list_menus_external( exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude items containing those allergens"), kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """List active menus with divisions and items served from snapshots.""" result = await db.execute( select(Menu) .options( selectinload(Menu.divisions).selectinload(MenuDivision.items), selectinload(Menu.items), ) .where(Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True) .order_by(Menu.sort_order) ) menus = result.scalars().all() exclude_flag_ids = set() if exclude_flags: exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} menus_data = [] for menu in menus: all_items = [i for i in (menu.items or []) if i.recipe_id is not None] staleness = await _compute_staleness(all_items, db) 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: snapshot = item.snapshot_json or {} confirmed_flags = snapshot.get("confirmed_flags", []) # Apply exclude filter if exclude_flag_ids: item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")} if item_flag_ids & exclude_flag_ids: continue stale_info = staleness.get(item.id, {"is_stale": False}) items_data.append({ "id": item.id, "display_name": snapshot.get("display_name", item.display_name), "description": snapshot.get("description", item.description), "price": snapshot.get("price", str(item.price) if item.price else None), "flags": [ {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"), "category": f.get("category"), "excludable": f.get("excludable", False)} for f in confirmed_flags ], "is_stale": stale_info.get("is_stale", False), "has_image": bool(item.image_path), }) if items_data: divisions_data.append({ "name": div.name, "items": items_data, }) menus_data.append({ "id": menu.id, "name": menu.name, "description": menu.description, "divisions": divisions_data, }) return menus_data @router.get("/menus/{menu_id}") async def get_menu_external( menu_id: int, exclude_flags: Optional[str] = Query(None), kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """Single menu detail for external consumption.""" result = await db.execute( select(Menu) .options( selectinload(Menu.divisions).selectinload(MenuDivision.items), selectinload(Menu.items), ) .where( Menu.id == menu_id, Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True, ) ) menu = result.scalar_one_or_none() if not menu: raise HTTPException(404, "Menu not found") exclude_flag_ids = set() if exclude_flags: exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} all_items = [i for i in (menu.items or []) if i.recipe_id is not None] staleness = await _compute_staleness(all_items, db) 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: snapshot = item.snapshot_json or {} confirmed_flags = snapshot.get("confirmed_flags", []) if exclude_flag_ids: item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")} if item_flag_ids & exclude_flag_ids: continue stale_info = staleness.get(item.id, {"is_stale": False}) items_data.append({ "id": item.id, "display_name": snapshot.get("display_name", item.display_name), "description": snapshot.get("description", item.description), "price": snapshot.get("price", str(item.price) if item.price else None), "flags": [ {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"), "category": f.get("category"), "excludable": f.get("excludable", False)} for f in confirmed_flags ], "is_stale": stale_info.get("is_stale", False), "has_image": bool(item.image_path), }) if items_data: divisions_data.append({ "name": div.name, "items": items_data, }) return { "id": menu.id, "name": menu.name, "description": menu.description, "divisions": divisions_data, } @router.get("/menus/{menu_id}/items/{item_id}/image") async def serve_menu_item_image_external( menu_id: int, item_id: int, kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), db: AsyncSession = Depends(get_db), ): """Serve a menu item image via API key authentication.""" result = await db.execute( select(MenuItem) .join(Menu, MenuItem.menu_id == Menu.id) .where( MenuItem.id == item_id, MenuItem.menu_id == menu_id, Menu.kitchen_id == kitchen.kitchen_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)