munch-ease-backend/api/meals.py

371 lines
12 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 persons
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
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
out: List[MealOut] = []
for m in result:
out.append(
MealOut(
id=m.id,
suggested_date=m.suggested_date,
consumed_date=m.consumed_date,
chefs=[_to_member(p) for p in m.chefs],
cleanup=[_to_member(p) for p in m.cleanup],
consumers=[_to_member(p) for p in 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")
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in 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)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in 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) -> persons.Person:
return persons.Person(id=m.id, 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)
hid = household["id"]
await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=domain_meal.id,
suggested_date=domain_meal.suggested_date,
consumed_date=domain_meal.consumed_date,
chefs=[_to_member(p) for p in domain_meal.chefs],
cleanup=[_to_member(p) for p in domain_meal.cleanup],
consumers=[_to_member(p) for p in 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) -> persons.Person:
return persons.Person(id=m.id, 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)
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
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=updated.id,
suggested_date=updated.suggested_date,
consumed_date=updated.consumed_date,
chefs=[_to_member(p) for p in updated.chefs],
cleanup=[_to_member(p) for p in updated.cleanup],
consumers=[_to_member(p) for p in 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)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
__all__ = ["router", "validate_meal"]