from fastapi import APIRouter, Depends, HTTPException, Query, status, Body from sqlalchemy.orm import Session from typing import List import httpx import json from datetime import datetime, timedelta from bs4 import BeautifulSoup from database import get_db from models import Recipe, MenuPlan from schemas import RecipeCreate, RecipeRead, RecipeUpdate from services import ai_service router = APIRouter(prefix="/api/recipes", tags=["recipes"]) def _current_monday(): today = datetime.utcnow().date() return today - timedelta(days=today.weekday()) @router.get("", response_model=List[RecipeRead]) def list_recipes(meal_type: str = Query(None), db: Session = Depends(get_db)): """List all recipes. Optional ?meal_type=dinner.""" query = db.query(Recipe) if meal_type: query = query.filter(Recipe.meal_type == meal_type) return query.all() @router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED) def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)): """Create a new recipe.""" db_recipe = Recipe(**recipe.model_dump()) db.add(db_recipe) db.commit() db.refresh(db_recipe) return db_recipe @router.get("/{id}", response_model=RecipeRead) def get_recipe(id: int, db: Session = Depends(get_db)): """Get a single recipe.""" recipe = db.query(Recipe).filter(Recipe.id == id).first() if not recipe: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") return recipe @router.put("/{id}", response_model=RecipeRead) def update_recipe(id: int, update: RecipeUpdate, db: Session = Depends(get_db)): """Update a recipe.""" recipe = db.query(Recipe).filter(Recipe.id == id).first() if not recipe: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") for field, value in update.model_dump(exclude_unset=True).items(): setattr(recipe, field, value) db.commit() db.refresh(recipe) return recipe @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_recipe(id: int, db: Session = Depends(get_db)): """Delete a recipe.""" recipe = db.query(Recipe).filter(Recipe.id == id).first() if not recipe: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") db.delete(recipe) db.commit() @router.post("/import") async def import_recipe(request: dict = Body(default={}), db: Session = Depends(get_db)): """Import a recipe from a URL.""" url = (request.get("url") or "").strip() if not url: raise HTTPException(status_code=400, detail="URL is required") # Fetch the page try: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() except Exception as e: raise HTTPException(status_code=400, detail=f"Could not fetch URL: {e}") soup = BeautifulSoup(resp.text, "html.parser") recipe_data = None # Try JSON-LD structured data first for script in soup.find_all("script", type="application/ld+json"): try: ld = json.loads(script.string or "") # Handle @graph wrapper if isinstance(ld, dict) and ld.get("@graph"): ld = next((x for x in ld["@graph"] if x.get("@type") == "Recipe"), None) if isinstance(ld, list): ld = next((x for x in ld if x.get("@type") == "Recipe"), None) if isinstance(ld, dict) and ld.get("@type") == "Recipe": recipe_data = ld break except Exception: continue if recipe_data: # Map JSON-LD Recipe schema to our format def parse_duration(iso): if not iso: return None import re m = re.search(r'(\d+)M', iso) h = re.search(r'(\d+)H', iso) return (int(h.group(1)) * 60 if h else 0) + (int(m.group(1)) if m else 0) raw_ingredients = recipe_data.get("recipeIngredient") or [] ingredients = [{"name": ing, "quantity": 1.0, "unit": ""} for ing in raw_ingredients] raw_instructions = recipe_data.get("recipeInstructions") or [] if isinstance(raw_instructions, list): steps = [] for step in raw_instructions: if isinstance(step, dict): steps.append(step.get("text", "")) else: steps.append(str(step)) instructions = " ".join(steps) else: instructions = str(raw_instructions) name = recipe_data.get("name") or "Imported Recipe" time_minutes = parse_duration(recipe_data.get("totalTime") or recipe_data.get("cookTime")) serves_raw = recipe_data.get("recipeYield") if isinstance(serves_raw, list): serves_raw = serves_raw[0] try: serves = int(str(serves_raw).split()[0]) if serves_raw else 2 except Exception: serves = 2 recipe_category = str(recipe_data.get("recipeCategory") or "").lower() if "breakfast" in recipe_category or "brunch" in recipe_category: meal_type = "breakfast" elif "lunch" in recipe_category or "salad" in recipe_category or "sandwich" in recipe_category: meal_type = "lunch" else: meal_type = "dinner" parsed = { "name": name, "meal_type": meal_type, "ingredients": ingredients, "instructions": instructions, "time_minutes": time_minutes, "serves": serves, } else: # Fall back to AI parsing of visible text text = soup.get_text(separator=" ", strip=True) try: result = await ai_service.parse_recipe_from_text(text) except Exception as e: raise HTTPException(status_code=503, detail=f"AI parsing failed: {e}") parsed = result.get("recipe", {}) # Save recipe to DB db_recipe = Recipe( name=parsed.get("name", "Imported Recipe"), meal_type=parsed.get("meal_type", "dinner"), ingredients=json.dumps(parsed.get("ingredients", [])), instructions=parsed.get("instructions", ""), estimated_time_minutes=parsed.get("time_minutes"), servings=parsed.get("serves", 2), source="import", description=parsed.get("description", ""), ) db.add(db_recipe) db.commit() db.refresh(db_recipe) # Add to current week's menu plan monday = _current_monday() menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if menu_plan: try: plan_ids = json.loads(menu_plan.plan) except Exception: plan_ids = [] plan_ids.append(db_recipe.id) menu_plan.plan = json.dumps(plan_ids) db.commit() else: menu_plan = MenuPlan( week_start=monday, plan=json.dumps([db_recipe.id]), ) db.add(menu_plan) db.commit() return {"recipe": RecipeRead.from_orm(db_recipe)}