diff options
Diffstat (limited to 'routers/recipes.py')
| -rw-r--r-- | routers/recipes.py | 144 |
1 files changed, 142 insertions, 2 deletions
diff --git a/routers/recipes.py b/routers/recipes.py index f215062..000f945 100644 --- a/routers/recipes.py +++ b/routers/recipes.py @@ -1,14 +1,24 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status +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 +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.""" @@ -48,3 +58,133 @@ def delete_recipe(id: int, db: Session = Depends(get_db)): 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)} |
