summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
Diffstat (limited to 'routers')
-rw-r--r--routers/__init__.py0
-rw-r--r--routers/grocery.py186
-rw-r--r--routers/meals.py97
-rw-r--r--routers/menus.py138
-rw-r--r--routers/pantry.py101
-rw-r--r--routers/recipes.py50
6 files changed, 572 insertions, 0 deletions
diff --git a/routers/__init__.py b/routers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/routers/__init__.py
diff --git a/routers/grocery.py b/routers/grocery.py
new file mode 100644
index 0000000..cde2068
--- /dev/null
+++ b/routers/grocery.py
@@ -0,0 +1,186 @@
+from fastapi import APIRouter, Depends, HTTPException, Body
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+import json
+
+from database import get_db
+from schemas import GroceryListRead
+from models import GroceryList, MenuPlan, Recipe, Ingredient
+from services import pantry_service, ai_service
+
+router = APIRouter(prefix="/api/grocery", tags=["grocery"])
+ai_router = APIRouter(prefix="/api/ai", tags=["ai"])
+
+
+@router.get("")
+async def list_grocery_lists(db: Session = Depends(get_db)):
+ """List all grocery lists."""
+ lists = db.query(GroceryList).order_by(GroceryList.generated_at.desc()).all()
+ return [GroceryListRead.from_orm(l) for l in lists]
+
+
+@router.get("/current")
+async def get_current_grocery_list(db: Session = Depends(get_db)):
+ """Get grocery list for current week."""
+ today = datetime.utcnow().date()
+ monday = today - timedelta(days=today.weekday())
+
+ grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="No grocery list for current week")
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@router.get("/{id}")
+async def get_grocery_list(id: int, db: Session = Depends(get_db)):
+ """Get a grocery list by ID."""
+ grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="Grocery list not found")
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@router.post("/generate")
+async def generate_grocery_list(db: Session = Depends(get_db)):
+ """Generate a grocery list for the current week."""
+ # Get current week's Monday
+ today = datetime.utcnow().date()
+ monday = today - timedelta(days=today.weekday())
+
+ # Fetch current MenuPlan
+ menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first()
+ if not menu_plan:
+ raise HTTPException(status_code=404, detail="No menu plan for current week. Generate a menu first.")
+
+ # Parse the plan JSON
+ try:
+ plan_dict = json.loads(menu_plan.plan)
+ except json.JSONDecodeError:
+ raise HTTPException(status_code=500, detail="Invalid menu plan data")
+
+ # Collect all recipe IDs and fetch recipes
+ recipe_ids = set()
+ for day_meals in plan_dict.values():
+ if isinstance(day_meals, dict):
+ for recipe_id in day_meals.values():
+ if isinstance(recipe_id, int):
+ recipe_ids.add(recipe_id)
+
+ recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else []
+ recipe_map = {r.id: r for r in recipes}
+
+ # Build menu_plan_for_ai
+ menu_plan_for_ai = {}
+ for day, day_meals in plan_dict.items():
+ menu_plan_for_ai[day] = {}
+ for meal_type, recipe_id in day_meals.items():
+ recipe = recipe_map.get(recipe_id)
+ if recipe:
+ try:
+ ingredients = json.loads(recipe.ingredients)
+ except (json.JSONDecodeError, TypeError):
+ ingredients = []
+ menu_plan_for_ai[day][meal_type] = {
+ "name": recipe.name,
+ "ingredients": ingredients,
+ }
+
+ # Build pantry context and generate grocery list
+ try:
+ pantry_context = pantry_service.build_pantry_context(db)
+ ai_result = await ai_service.generate_grocery_list(menu_plan_for_ai, pantry_context)
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, Exception) as e:
+ raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}")
+
+ # Upsert GroceryList
+ existing_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first()
+ if existing_list:
+ existing_list.items = json.dumps(ai_result.get("items", []))
+ existing_list.total_estimate = ai_result.get("total_estimate", 0.0)
+ existing_list.notes = ai_result.get("shopping_notes", "")
+ existing_list.generated_at = datetime.utcnow()
+ grocery_list = existing_list
+ else:
+ grocery_list = GroceryList(
+ generated_for=monday,
+ items=json.dumps(ai_result.get("items", [])),
+ total_estimate=ai_result.get("total_estimate", 0.0),
+ notes=ai_result.get("shopping_notes", ""),
+ generated_at=datetime.utcnow(),
+ )
+ db.add(grocery_list)
+
+ db.commit()
+ db.refresh(grocery_list)
+
+ return {
+ "grocery_list": GroceryListRead.from_orm(grocery_list),
+ "items": ai_result.get("items", []),
+ "shopping_notes": ai_result.get("shopping_notes", ""),
+ }
+
+
+@router.put("/{id}/purchased")
+async def mark_purchased(id: int, db: Session = Depends(get_db)):
+ """Mark a grocery list as purchased and update pantry."""
+ grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="Grocery list not found")
+
+ # Mark as purchased
+ grocery_list.is_purchased = True
+
+ # Parse items
+ try:
+ items = json.loads(grocery_list.items)
+ except json.JSONDecodeError:
+ items = []
+
+ # Update pantry for each item
+ for item in items:
+ if isinstance(item, dict):
+ name = item.get("name")
+ quantity = item.get("quantity", 0)
+ unit = item.get("unit", "")
+
+ if name:
+ # Check if ingredient exists
+ existing_ingredient = db.query(Ingredient).filter(Ingredient.name == name).first()
+ if existing_ingredient:
+ existing_ingredient.quantity += quantity
+ existing_ingredient.updated_at = datetime.utcnow()
+ else:
+ # Create new ingredient
+ new_ingredient = Ingredient(
+ name=name,
+ quantity=quantity,
+ unit=unit,
+ category=item.get("store_section"),
+ )
+ db.add(new_ingredient)
+
+ db.commit()
+ db.refresh(grocery_list)
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@ai_router.post("/suggest-recipe")
+async def suggest_recipe_endpoint(db: Session = Depends(get_db)):
+ """Suggest a recipe based on current pantry."""
+ try:
+ context = pantry_service.build_pantry_context(db)
+ result = await ai_service.suggest_recipe(context)
+ return result
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, Exception) as e:
+ raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}")
+
+
+@ai_router.get("/models")
+async def list_models():
+ """List available Ollama models."""
+ models = await ai_service.get_available_models()
+ return {"models": models}
diff --git a/routers/meals.py b/routers/meals.py
new file mode 100644
index 0000000..99c5243
--- /dev/null
+++ b/routers/meals.py
@@ -0,0 +1,97 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from typing import List, Dict, Any
+from collections import defaultdict
+
+from database import get_db
+from models import MealLog, MealIngredient
+from schemas import MealLogCreate, MealLogRead, MealIngredientCreate
+
+router = APIRouter(prefix="/api/meals", tags=["meals"])
+
+
+@router.get("", response_model=List[MealLogRead])
+def list_meals(
+ days: int = Query(30),
+ meal_type: str = Query(None),
+ db: Session = Depends(get_db)
+):
+ """List meal log entries. Optional query params: ?days=30 (default 30, filters entries from last N days), ?meal_type=dinner. Returns entries ordered by eaten_at desc."""
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
+ query = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date)
+
+ if meal_type:
+ query = query.filter(MealLog.meal_type == meal_type)
+
+ return query.order_by(MealLog.eaten_at.desc()).all()
+
+
+@router.get("/stats", response_model=Dict[str, Any])
+def meal_stats(db: Session = Depends(get_db)):
+ """Return meal frequency stats. Query last 30 days, count occurrences of each meal_name. Return {"meal_frequency": {"Pasta Carbonara": 3, ...}, "total_meals": 15, "by_type": {"dinner": 10, "lunch": 5}}."""
+ cutoff_date = datetime.utcnow() - timedelta(days=30)
+ meals = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date).all()
+
+ meal_frequency = defaultdict(int)
+ meal_by_type = defaultdict(int)
+
+ for meal in meals:
+ meal_frequency[meal.meal_name] += 1
+ meal_by_type[meal.meal_type] += 1
+
+ return {
+ "meal_frequency": dict(meal_frequency),
+ "total_meals": len(meals),
+ "by_type": dict(meal_by_type)
+ }
+
+
+@router.get("/{id}", response_model=MealLogRead)
+def get_meal(id: int, db: Session = Depends(get_db)):
+ """Get a single meal log entry."""
+ meal = db.query(MealLog).filter(MealLog.id == id).first()
+ if not meal:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found")
+ return meal
+
+
+@router.post("", response_model=MealLogRead, status_code=status.HTTP_201_CREATED)
+def create_meal(meal: MealLogCreate, db: Session = Depends(get_db)):
+ """Log a new meal. If ingredients list provided, create MealIngredient records linked to this meal."""
+ # Create the MealLog entry
+ meal_data = meal.model_dump(exclude={"ingredients"})
+ if meal_data["eaten_at"] is None:
+ meal_data["eaten_at"] = datetime.utcnow()
+
+ db_meal = MealLog(**meal_data)
+ db.add(db_meal)
+ db.commit()
+ db.refresh(db_meal)
+
+ # Create MealIngredient entries if provided
+ if meal.ingredients:
+ for ingredient in meal.ingredients:
+ meal_ingredient = MealIngredient(
+ meal_log_id=db_meal.id,
+ ingredient_name=ingredient.ingredient_name,
+ quantity_used=ingredient.quantity_used,
+ unit=ingredient.unit
+ )
+ db.add(meal_ingredient)
+
+ db.commit()
+ db.refresh(db_meal)
+
+ return db_meal
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_meal(id: int, db: Session = Depends(get_db)):
+ """Delete a meal log entry."""
+ meal = db.query(MealLog).filter(MealLog.id == id).first()
+ if not meal:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found")
+
+ db.delete(meal)
+ db.commit()
diff --git a/routers/menus.py b/routers/menus.py
new file mode 100644
index 0000000..49c4654
--- /dev/null
+++ b/routers/menus.py
@@ -0,0 +1,138 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta, date
+import json
+
+from database import get_db
+from schemas import MenuPlanRead
+from models import MenuPlan, Recipe
+from services import pantry_service, ai_service
+from config import settings
+
+router = APIRouter(prefix="/api/menus", tags=["menus"])
+
+
+@router.get("/current")
+async def get_current_menu(db: Session = Depends(get_db)):
+ """Get menu plan for current week."""
+ today = datetime.utcnow().date()
+ monday = today - timedelta(days=today.weekday())
+
+ 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()
+ else:
+ week_start = week_start
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid week_start date")
+ else:
+ today = datetime.utcnow().date()
+ week_start = today - timedelta(days=today.weekday())
+
+ # Build pantry context and generate menu
+ try:
+ pantry_context = pantry_service.build_pantry_context(db)
+ ai_result = await ai_service.generate_weekly_menu(pantry_context)
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, Exception) as e:
+ raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}")
+
+ # Save recipes and build plan dict
+ week_plan = ai_result.get("week_plan", {})
+ plan_dict = {}
+
+ for day, meals in week_plan.items():
+ plan_dict[day] = {}
+ for meal_type, meal_data in meals.items():
+ if isinstance(meal_data, dict) and "name" in meal_data:
+ meal_name = meal_data["name"]
+
+ # Check if recipe exists by name
+ existing_recipe = db.query(Recipe).filter(Recipe.name == meal_name).first()
+ if existing_recipe:
+ recipe_id = existing_recipe.id
+ else:
+ # Create new recipe
+ new_recipe = Recipe(
+ name=meal_name,
+ meal_type=meal_type,
+ ingredients=json.dumps(meal_data.get("ingredients", [])),
+ instructions=meal_data.get("instructions", ""),
+ estimated_time_minutes=meal_data.get("time_minutes", 30),
+ servings=meal_data.get("serves", 2),
+ source="ai",
+ )
+ db.add(new_recipe)
+ db.flush()
+ recipe_id = new_recipe.id
+
+ plan_dict[day][meal_type] = recipe_id
+
+ # Upsert MenuPlan
+ existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first()
+ if existing_plan:
+ existing_plan.plan = json.dumps(plan_dict)
+ existing_plan.generated_at = datetime.utcnow()
+ existing_plan.model_used = settings.model_name
+ menu = existing_plan
+ else:
+ menu = MenuPlan(
+ week_start=week_start,
+ plan=json.dumps(plan_dict),
+ generated_at=datetime.utcnow(),
+ model_used=settings.model_name,
+ )
+ db.add(menu)
+
+ db.commit()
+ db.refresh(menu)
+
+ return {
+ "menu_plan": MenuPlanRead.from_orm(menu),
+ "week_plan": week_plan,
+ "notes": ai_result.get("notes", ""),
+ }
+
+
+@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("/{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"}
diff --git a/routers/pantry.py b/routers/pantry.py
new file mode 100644
index 0000000..d6638f8
--- /dev/null
+++ b/routers/pantry.py
@@ -0,0 +1,101 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from typing import List
+
+from database import get_db
+from models import Ingredient
+from schemas import IngredientCreate, IngredientRead, IngredientUpdate
+
+router = APIRouter(prefix="/api/pantry", tags=["pantry"])
+
+
+@router.get("", response_model=List[IngredientRead])
+def list_ingredients(
+ expiring_soon: bool = Query(False),
+ db: Session = Depends(get_db)
+):
+ """List all ingredients. Optional query param ?expiring_soon=true filters to ingredients with expiry_date within 7 days from today."""
+ query = db.query(Ingredient)
+
+ if expiring_soon:
+ today = datetime.utcnow().date()
+ seven_days_later = today + timedelta(days=7)
+ query = query.filter(
+ (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later)
+ )
+
+ return query.all()
+
+
+@router.get("/expiring", response_model=List[IngredientRead])
+def list_expiring_ingredients(db: Session = Depends(get_db)):
+ """Ingredients expiring within 7 days."""
+ today = datetime.utcnow().date()
+ seven_days_later = today + timedelta(days=7)
+
+ return db.query(Ingredient).filter(
+ (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later)
+ ).all()
+
+
+@router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
+def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)):
+ """Create a new ingredient."""
+ db_ingredient = Ingredient(**ingredient.model_dump())
+ db.add(db_ingredient)
+ db.commit()
+ db.refresh(db_ingredient)
+ return db_ingredient
+
+
+@router.put("/{id}", response_model=IngredientRead)
+def update_ingredient(id: int, ingredient: IngredientUpdate, db: Session = Depends(get_db)):
+ """Update ingredient fields (partial update)."""
+ db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first()
+ if not db_ingredient:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found")
+
+ update_data = ingredient.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(db_ingredient, field, value)
+
+ db_ingredient.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(db_ingredient)
+ return db_ingredient
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_ingredient(id: int, db: Session = Depends(get_db)):
+ """Delete an ingredient."""
+ db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first()
+ if not db_ingredient:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found")
+
+ db.delete(db_ingredient)
+ db.commit()
+
+
+@router.post("/bulk", response_model=List[IngredientRead])
+def bulk_upsert_ingredients(ingredients: List[IngredientCreate], db: Session = Depends(get_db)):
+ """Upsert list of ingredients. If an ingredient with same name exists, add the quantity (accumulate). If not, create new."""
+ result = []
+
+ for ingredient_data in ingredients:
+ existing = db.query(Ingredient).filter(Ingredient.name == ingredient_data.name).first()
+
+ if existing:
+ existing.quantity += ingredient_data.quantity
+ existing.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(existing)
+ result.append(existing)
+ else:
+ new_ingredient = Ingredient(**ingredient_data.model_dump())
+ db.add(new_ingredient)
+ db.commit()
+ db.refresh(new_ingredient)
+ result.append(new_ingredient)
+
+ return result
diff --git a/routers/recipes.py b/routers/recipes.py
new file mode 100644
index 0000000..f215062
--- /dev/null
+++ b/routers/recipes.py
@@ -0,0 +1,50 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from typing import List
+
+from database import get_db
+from models import Recipe
+from schemas import RecipeCreate, RecipeRead, RecipeUpdate
+
+router = APIRouter(prefix="/api/recipes", tags=["recipes"])
+
+
+@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.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()