2025-11-01 06:24:15 +00:00
|
|
|
from __future__ import annotations
|
2025-10-18 05:50:43 +00:00
|
|
|
|
2025-11-01 06:24:15 +00:00
|
|
|
import datetime
|
|
|
|
|
from typing import List, Optional
|
2025-10-18 05:50:43 +00:00
|
|
|
|
2025-11-01 06:24:15 +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
|
|
|
|
2025-11-01 06:11:09 +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
|
|
|
|
2025-11-01 06:24:15 +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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(m.chefs),
|
|
|
|
|
cleanup=list(m.cleanup),
|
|
|
|
|
consumers=list(m.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(meal.chefs),
|
|
|
|
|
cleanup=list(meal.cleanup),
|
|
|
|
|
consumers=list(meal.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(meal.chefs),
|
|
|
|
|
cleanup=list(meal.cleanup),
|
|
|
|
|
consumers=list(meal.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
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
|
2025-11-01 08:58:33 +00:00
|
|
|
def _from_member(m: MemberRef) -> MemberRef:
|
|
|
|
|
return MemberRef(id=m.id, display_name=m.display_name)
|
2025-11-01 06:24:15 +00:00
|
|
|
|
|
|
|
|
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 via repositories
|
2025-11-01 06:24:15 +00:00
|
|
|
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:
|
|
|
|
|
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.",
|
|
|
|
|
)
|
2025-11-01 06:24:15 +00:00
|
|
|
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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(domain_meal.chefs),
|
|
|
|
|
cleanup=list(domain_meal.cleanup),
|
|
|
|
|
consumers=list(domain_meal.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
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")
|
|
|
|
|
|
2025-11-01 08:58:33 +00:00
|
|
|
def _from_member(m: MemberRef) -> MemberRef:
|
|
|
|
|
return MemberRef(id=m.id, display_name=m.display_name)
|
2025-11-01 06:24:15 +00:00
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
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))))}")
|
2025-11-01 06:24:15 +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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(updated.chefs),
|
|
|
|
|
cleanup=list(updated.cleanup),
|
|
|
|
|
consumers=list(updated.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
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,
|
2025-11-01 08:58:33 +00:00
|
|
|
chefs=list(meal.chefs),
|
|
|
|
|
cleanup=list(meal.cleanup),
|
|
|
|
|
consumers=list(meal.consumers),
|
2025-11-01 06:24:15 +00:00
|
|
|
recipes=meal.recipes,
|
|
|
|
|
extra_ingredients=meal.extra_ingredients,
|
|
|
|
|
purchase_date=meal.purchase_date,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 06:11:09 +00:00
|
|
|
__all__ = ["router", "validate_meal"]
|