munch-ease-backend/api/meals.py

466 lines
16 KiB
Python
Raw Normal View History

from __future__ import annotations
2025-10-18 05:50:43 +00:00
import datetime
from typing import List, Optional
2025-10-18 05:50:43 +00:00
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
2025-10-18 05:50:43 +00:00
# Keep validate_meal import surface for tests that reference api.meals.validate_meal
from meals.service import validate_meal
2025-10-18 05:50:43 +00:00
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)
2025-11-02 07:10:27 +00:00
# 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 == "":
2025-11-02 07:55:55 +00:00
return error_response(
request, 400, "Ingredient must include a name or line or a product"
)
2025-11-02 07:14:03 +00:00
# quantity must be > 0
try:
q = float(getattr(ing, "quantity", 0))
except Exception:
q = getattr(ing, "quantity", 0)
if isinstance(q, (int, float)) and q <= 0:
return error_response(request, 400, "Ingredient quantity must be greater than 0")
2025-11-02 06:45:12 +00:00
# Proactive validation via repositories
hid = household["id"]
2025-11-02 06:45:12 +00:00
# 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:
2025-11-02 06:51:10 +00:00
return error_response(
request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}"
)
2025-11-02 06:45:12 +00:00
# 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:
2025-11-02 06:51:10 +00:00
return error_response(
request,
400,
f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}",
)
2025-11-02 06:45:12 +00:00
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)
2025-11-02 06:45:12 +00:00
# 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:
2025-11-02 06:51:10 +00:00
return error_response(
request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}"
)
2025-11-02 06:45:12 +00:00
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:
2025-11-02 06:51:10 +00:00
return error_response(
request,
400,
f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}",
)
2025-11-02 07:10:27 +00:00
# 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 == "":
2025-11-02 07:55:55 +00:00
return error_response(
request, 400, "Ingredient must include a name or line or a product"
)
2025-11-02 07:14:03 +00:00
try:
q = float(getattr(ing, "quantity", 0))
except Exception:
q = getattr(ing, "quantity", 0)
if isinstance(q, (int, float)) and q <= 0:
return error_response(request, 400, "Ingredient quantity must be greater than 0")
2025-11-02 07:10:27 +00:00
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"]