from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy.orm import Session from datetime import datetime, timedelta, date import json from database import get_db from schemas import MenuPlanRead, RecipeRead from models import MenuPlan, Recipe from services import pantry_service, ai_service from config import settings router = APIRouter(prefix="/api/menus", tags=["menus"]) def _current_monday(): today = datetime.utcnow().date() return today - timedelta(days=today.weekday()) @router.get("/current") async def get_current_menu(db: Session = Depends(get_db)): """Get menu plan for current week.""" monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: raise HTTPException(status_code=404, detail="No menu plan for current week") return MenuPlanRead.from_orm(menu) @router.post("/generate") async def generate_menu(request: dict = None, db: Session = Depends(get_db)): """Generate a weekly menu using AI.""" # Parse request body week_start = None if request and isinstance(request, dict): week_start = request.get("week_start") # Determine week_start date if week_start: try: if isinstance(week_start, str): week_start = datetime.fromisoformat(week_start).date() except Exception: raise HTTPException(status_code=400, detail="Invalid week_start date") else: week_start = _current_monday() # Build pantry context and generate menu try: pantry_context = pantry_service.build_pantry_context(db) user_notes = request.get("user_notes") if request and isinstance(request, dict) else None ai_result = await ai_service.generate_weekly_menu(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") # Save recipes and build plan list (flat array of IDs) recipes_ai = ai_result.get("recipes", []) plan_ids = [] valid_recipes = [r for r in recipes_ai if isinstance(r, dict) and "name" in r] all_names = [r["name"] for r in valid_recipes] existing = db.query(Recipe).filter(Recipe.name.in_(all_names)).all() existing_by_name = {r.name: r for r in existing} for recipe_data in valid_recipes: meal_name = recipe_data["name"] if meal_name in existing_by_name: recipe_id = existing_by_name[meal_name].id else: new_recipe = Recipe( name=meal_name, meal_type=recipe_data.get("meal_type", "dinner"), ingredients=json.dumps(recipe_data.get("ingredients", [])), instructions=recipe_data.get("instructions", ""), estimated_time_minutes=recipe_data.get("time_minutes", 30), servings=recipe_data.get("serves", 2), source="ai", description=recipe_data.get("description", ""), ) db.add(new_recipe) db.flush() recipe_id = new_recipe.id plan_ids.append(recipe_id) ai_notes = ai_result.get("notes", "") existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() if existing_plan: existing_plan.plan = json.dumps(plan_ids) existing_plan.generated_at = datetime.utcnow() existing_plan.model_used = settings.model_name existing_plan.notes = ai_notes menu = existing_plan else: menu = MenuPlan( week_start=week_start, plan=json.dumps(plan_ids), generated_at=datetime.utcnow(), model_used=settings.model_name, notes=ai_notes, ) db.add(menu) db.commit() db.refresh(menu) return { "menu_plan": MenuPlanRead.from_orm(menu), "recipes": recipes_ai, "notes": menu.notes or "", } @router.get("") async def list_menus(db: Session = Depends(get_db)): """List all menu plans.""" menus = db.query(MenuPlan).order_by(MenuPlan.week_start.desc()).all() return [MenuPlanRead.from_orm(m) for m in menus] @router.get("/{week_start}") async def get_menu(week_start: date, db: Session = Depends(get_db)): """Get menu plan for a specific week.""" menu = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() if not menu: raise HTTPException(status_code=404, detail="Menu plan not found") return MenuPlanRead.from_orm(menu) @router.delete("/current") async def delete_current_menu(db: Session = Depends(get_db)): """Delete the menu plan for the current week.""" monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: raise HTTPException(status_code=404, detail="No menu plan for current week") db.delete(menu) db.commit() return {"status": "deleted"} @router.delete("/current/recipes/{recipe_id}") async def remove_recipe_from_menu(recipe_id: int, db: Session = Depends(get_db)): """Remove a single recipe from the current week's plan.""" monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: raise HTTPException(status_code=404, detail="No menu plan for current week") try: plan_ids = json.loads(menu.plan) except json.JSONDecodeError: raise HTTPException(status_code=500, detail="Invalid menu plan data") if recipe_id not in plan_ids: raise HTTPException(status_code=404, detail="Recipe not in current menu plan") plan_ids.remove(recipe_id) menu.plan = json.dumps(plan_ids) db.commit() return {"status": "removed", "plan": plan_ids} @router.post("/current/recipes/{recipe_id}/swap") async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), db: Session = Depends(get_db)): """Swap a single recipe in the current week's plan with a new AI suggestion.""" monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: raise HTTPException(status_code=404, detail="No menu plan for current week") try: plan_ids = json.loads(menu.plan) except json.JSONDecodeError: raise HTTPException(status_code=500, detail="Invalid menu plan data") # Find the recipe being swapped old_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() if not old_recipe: raise HTTPException(status_code=404, detail="Recipe not found") meal_type = old_recipe.meal_type # Get names of all other recipes in the plan to avoid repeats other_ids = [rid for rid in plan_ids if rid != recipe_id] other_recipes = db.query(Recipe).filter(Recipe.id.in_(other_ids)).all() if other_ids else [] existing_names = [r.name for r in other_recipes] # Generate replacement user_notes = request.get("user_notes") if request else None try: pantry_context = pantry_service.build_pantry_context(db) ai_result = await ai_service.generate_replacement_recipe(meal_type, existing_names, pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") recipe_data = ai_result.get("recipe", {}) if not recipe_data or "name" not in recipe_data: raise HTTPException(status_code=503, detail="AI returned invalid recipe data") # Save new recipe new_recipe = Recipe( name=recipe_data["name"], meal_type=recipe_data.get("meal_type", meal_type), ingredients=json.dumps(recipe_data.get("ingredients", [])), instructions=recipe_data.get("instructions", ""), estimated_time_minutes=recipe_data.get("time_minutes", 30), servings=recipe_data.get("serves", 2), source="ai", ) db.add(new_recipe) db.flush() # Swap the ID in the plan idx = plan_ids.index(recipe_id) plan_ids[idx] = new_recipe.id menu.plan = json.dumps(plan_ids) db.commit() db.refresh(new_recipe) return { "recipe": { "id": new_recipe.id, "name": new_recipe.name, "meal_type": new_recipe.meal_type, "ingredients": recipe_data.get("ingredients", []), "instructions": new_recipe.instructions, "estimated_time_minutes": new_recipe.estimated_time_minutes, "servings": new_recipe.servings, } } @router.post("/current/recipes") async def add_recipe_to_menu(request: dict = Body(default={}), db: Session = Depends(get_db)): """Generate and add a single recipe by description.""" description = (request.get("description") or "").strip() if not description: raise HTTPException(status_code=400, detail="Description is required") monday = _current_monday() menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() # Get existing recipe names to avoid duplicates existing_names = [] if menu_plan: try: plan_ids = json.loads(menu_plan.plan) existing = db.query(Recipe).filter(Recipe.id.in_(plan_ids)).all() if plan_ids else [] existing_names = [r.name for r in existing] except Exception: pass pantry_context = pantry_service.build_pantry_context(db) try: result = await ai_service.generate_single_recipe(description, existing_names, pantry_context) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama error: {str(e)}") recipe_data = result.get("recipe", {}) if not recipe_data.get("name"): raise HTTPException(status_code=503, detail="AI returned invalid recipe") new_recipe = Recipe( name=recipe_data["name"], description=recipe_data.get("description", ""), meal_type=recipe_data.get("meal_type", "dinner"), ingredients=json.dumps(recipe_data.get("ingredients", [])), instructions=recipe_data.get("instructions", ""), estimated_time_minutes=recipe_data.get("time_minutes"), servings=recipe_data.get("serves", 2), source="ai", ) db.add(new_recipe) db.flush() if menu_plan: try: plan_ids = json.loads(menu_plan.plan) except Exception: plan_ids = [] plan_ids.append(new_recipe.id) menu_plan.plan = json.dumps(plan_ids) else: menu_plan = MenuPlan( week_start=monday, plan=json.dumps([new_recipe.id]), ) db.add(menu_plan) db.commit() db.refresh(new_recipe) return {"recipe": RecipeRead.from_orm(new_recipe)} @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" menu = db.query(MenuPlan).filter(MenuPlan.id == id).first() if not menu: raise HTTPException(status_code=404, detail="Menu plan not found") db.delete(menu) db.commit() return {"status": "deleted"}