from __future__ import annotations import datetime from typing import List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Request, Response import meals import shopping import ingredients from common import ProblemDetails, ApiModel from api.dtos import MemberRef from api.deps import error_response from api.deps import get_db, get_household_from_slug # Keep validate_meal import surface for tests that reference api.meals.validate_meal from meals.service import validate_meal router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals"]) # MemberRef now imported from api.dtos class MealRecipeIn(ApiModel): meal_id: int recipe_id: int servings: float class MealIn(ApiModel): id: int = -1 suggested_date: datetime.datetime consumed_date: Optional[datetime.datetime] = None chefs: List[MemberRef] cleanup: List[MemberRef] consumers: List[MemberRef] recipes: List[MealRecipeIn] = [] extra_ingredients: List[ingredients.Ingredient] = [] class MealOut(ApiModel): id: int = -1 suggested_date: datetime.datetime consumed_date: Optional[datetime.datetime] = None chefs: List[MemberRef] cleanup: List[MemberRef] consumers: List[MemberRef] recipes: List[meals.MealRecipe] extra_ingredients: List[ingredients.Ingredient] purchase_date: Optional[datetime.datetime] = None class MarkConsumedBody(ApiModel): consumed_date: Optional[datetime.datetime] = None @router.get( "/upcoming", operation_id="getUpcomingMealsV2", summary="List upcoming meals in a date range (scoped)", ) async def get_upcoming_meals_scoped( household=Depends(get_household_from_slug), date_from: datetime.datetime = Query(..., alias="from"), to: datetime.datetime = Query(...), conn: aiosqlite.Connection = Depends(get_db), ) -> List[MealOut]: hid = household["id"] # Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped try: async for _ in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid): # if function exists, break immediately to use it break use_scoped = True except AttributeError: use_scoped = False result: List[meals.Meal] = [] if use_scoped: async for meal in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid): result.append(meal) else: # Temporary path: direct query with household_id filter async with conn.execute( f""" SELECT {",".join(meals.Meal.KEYS)} FROM Meal WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ? """, (date_from, to, hid), ) as cursor: async for row in cursor: result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)})) if not result: return [] # Load relateds similar to v1 await meals.bulk_load_participants(conn, result) for meal in result: await meals.load_recipes(conn, meal) await meals.load_extra_ingredients(conn, meal) # Map domain Meal -> outward MealOut out: List[MealOut] = [] for m in result: out.append( MealOut( id=m.id, suggested_date=m.suggested_date, consumed_date=m.consumed_date, chefs=list(m.chefs), cleanup=list(m.cleanup), consumers=list(m.consumers), recipes=m.recipes, extra_ingredients=m.extra_ingredients, purchase_date=m.purchase_date, ) ) return out @router.get( "/{meal_id}", operation_id="getMealV2", summary="Get a meal by id (scoped)", response_model=MealOut, responses={404: {"model": ProblemDetails}}, ) async def get_meal_scoped( meal_id: int, request: Request, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ) -> MealOut | Response: hid = household["id"] meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) if not meal: return error_response(request, 404, "Meal not found") return MealOut( id=meal.id, suggested_date=meal.suggested_date, consumed_date=meal.consumed_date, chefs=list(meal.chefs), cleanup=list(meal.cleanup), consumers=list(meal.consumers), recipes=meal.recipes, extra_ingredients=meal.extra_ingredients, purchase_date=meal.purchase_date, ) @router.post( "/{meal_id}/consumed", operation_id="markMealConsumedV2", summary="Mark a meal as consumed (scoped)", response_model=MealOut, responses={ 400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}, }, ) async def mark_meal_consumed_scoped( meal_id: int, request: Request, body: Optional[MarkConsumedBody] = None, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ) -> MealOut | Response: hid = household["id"] consumed_date: Optional[datetime.datetime] = None if body is not None: # Model aliasing handles consumedDate -> consumed_date consumed_date = getattr(body, "consumed_date", None) if consumed_date is not None and not getattr(consumed_date, "tzinfo", None): return error_response(request, 400, "Consumed date must include timezone") meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) if not meal: return error_response(request, 404, "Meal not found") await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) # Clear any outstanding meal request entries for this meal await shopping.remove_request(conn, person=None, meal=meal) return MealOut( id=meal.id, suggested_date=meal.suggested_date, consumed_date=meal.consumed_date, chefs=list(meal.chefs), cleanup=list(meal.cleanup), consumers=list(meal.consumers), recipes=meal.recipes, extra_ingredients=meal.extra_ingredients, purchase_date=meal.purchase_date, ) @router.post( "", operation_id="createMealV2", summary="Create a new meal (scoped)", response_model=MealOut, responses={400: {"model": ProblemDetails}}, ) async def create_meal_scoped( meal: MealIn, response: Response, request: Request, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ) -> MealOut | Response: # Validate using existing service logic # Map MealIn -> domain Meal def _from_member(m: MemberRef) -> MemberRef: return MemberRef(id=m.id, display_name=m.display_name) domain_meal = meals.Meal( id=meal.id, suggested_date=meal.suggested_date, consumed_date=meal.consumed_date, chefs=[_from_member(p) for p in meal.chefs], cleanup=[_from_member(p) for p in meal.cleanup], consumers=[_from_member(p) for p in meal.consumers], recipes=[ meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings) for r in meal.recipes ], extra_ingredients=list(meal.extra_ingredients), ) msg = meals.validate_meal(domain_meal) if msg: return error_response(request, 400, msg) # Disallow empty extra ingredients (no product and no textual content) for ing in domain_meal.extra_ingredients: name = (ing.name or "").strip() line = (ing.line or "").strip() has_product = getattr(ing, "product", None) is not None or ( getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 ) if not has_product and name == "" and line == "": return error_response(request, 400, "Ingredient must include a name or line or a product") # Proactive validation via repositories hid = household["id"] # Validate members exist as users (do not require household membership here to preserve existing behavior/tests) member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)} if member_ids: from users.repository import get_by_ids as get_users_by_ids users = await get_users_by_ids(conn, sorted(member_ids)) valid_ids = set(users.keys()) invalid = sorted(member_ids - valid_ids) if invalid: return error_response( request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}" ) # Validate recipes (existence and household scope) via recipes repository if domain_meal.recipes: from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id invalid_recipes: list[int] = [] for r in domain_meal.recipes: rid = int(r.recipe_id) if r.recipe_id is not None else -1 if rid < 0: invalid_recipes.append(rid) continue recipe = await find_recipe_by_id_scoped(conn, rid, hid) if not recipe: # Fallback to global existence if scoping isn't set on that record recipe = await find_recipe_by_id(conn, rid) if not recipe: invalid_recipes.append(rid) if invalid_recipes: return error_response( request, 400, f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}", ) try: await meals.insert_meal_scoped(conn, domain_meal, hid) except aiosqlite.IntegrityError: # Likely an invalid foreign key (unknown member or recipe id) return error_response( request, 400, "Invalid member or recipe id. Ensure participant IDs are valid household members and recipes exist.", ) response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}" return MealOut( id=domain_meal.id, suggested_date=domain_meal.suggested_date, consumed_date=domain_meal.consumed_date, chefs=list(domain_meal.chefs), cleanup=list(domain_meal.cleanup), consumers=list(domain_meal.consumers), recipes=domain_meal.recipes, extra_ingredients=domain_meal.extra_ingredients, purchase_date=domain_meal.purchase_date, ) @router.put( "/{meal_id}", operation_id="updateMealV2", summary="Update an existing meal (scoped)", response_model=MealOut, responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}}, ) async def update_meal_scoped( meal_id: int, meal: MealIn, request: Request, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ) -> MealOut | Response: if meal.id != meal_id: return error_response(request, 400, "Meal ID in URL does not match meal ID in body") hid = household["id"] existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid) if not existing: return error_response(request, 404, "Meal not found") def _from_member(m: MemberRef) -> MemberRef: return MemberRef(id=m.id, display_name=m.display_name) domain_meal = meals.Meal( id=meal.id, suggested_date=meal.suggested_date, consumed_date=meal.consumed_date, chefs=[_from_member(p) for p in meal.chefs], cleanup=[_from_member(p) for p in meal.cleanup], consumers=[_from_member(p) for p in meal.consumers], recipes=[ meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings) for r in meal.recipes ], extra_ingredients=list(meal.extra_ingredients), ) msg = meals.validate_meal(domain_meal) if msg: return error_response(request, 400, msg) # Proactive validation similar to create (user existence only) member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)} hid = household["id"] if member_ids: from users.repository import get_by_ids as get_users_by_ids users = await get_users_by_ids(conn, sorted(member_ids)) valid_ids = set(users.keys()) invalid = sorted(member_ids - valid_ids) if invalid: return error_response( request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}" ) if domain_meal.recipes: from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id invalid_recipes: list[int] = [] for r in domain_meal.recipes: rid = int(r.recipe_id) if r.recipe_id is not None else -1 if rid < 0: invalid_recipes.append(rid) continue recipe = await find_recipe_by_id_scoped(conn, rid, hid) if not recipe: recipe = await find_recipe_by_id(conn, rid) if not recipe: invalid_recipes.append(rid) if invalid_recipes: return error_response( request, 400, f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}", ) # Disallow empty extra ingredients on update as well for ing in domain_meal.extra_ingredients: name = (ing.name or "").strip() line = (ing.line or "").strip() has_product = getattr(ing, "product", None) is not None or ( getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 ) if not has_product and name == "" and line == "": return error_response(request, 400, "Ingredient must include a name or line or a product") await meals.update_meal(conn, domain_meal) # Return updated state updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid) assert updated is not None return MealOut( id=updated.id, suggested_date=updated.suggested_date, consumed_date=updated.consumed_date, chefs=list(updated.chefs), cleanup=list(updated.cleanup), consumers=list(updated.consumers), recipes=updated.recipes, extra_ingredients=updated.extra_ingredients, purchase_date=updated.purchase_date, ) @router.delete( "/{meal_id}", operation_id="deleteMealV2", summary="Delete a meal (scoped)", response_model=MealOut, responses={404: {"model": ProblemDetails}}, ) async def delete_meal_scoped( meal_id: int, request: Request, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ) -> MealOut | Response: hid = household["id"] meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) if not meal: return error_response(request, 404, "Meal not found") # Remove outstanding requests for this meal in current household try: from shopping.repository import remove_meal_request_scoped await remove_meal_request_scoped(conn, meal_id, hid) except Exception: # Fallback: remove regardless of household (legacy cleanup) await shopping.remove_request(conn, person=None, meal=meal) await meals.delete_meal(conn, meal.id) return MealOut( id=meal.id, suggested_date=meal.suggested_date, consumed_date=meal.consumed_date, chefs=list(meal.chefs), cleanup=list(meal.cleanup), consumers=list(meal.consumers), recipes=meal.recipes, extra_ingredients=meal.extra_ingredients, purchase_date=meal.purchase_date, ) __all__ = ["router", "validate_meal"]