2025-11-01 03:21:34 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import datetime
|
2025-11-01 03:30:45 +00:00
|
|
|
from typing import List, Optional
|
2025-11-01 03:21:34 +00:00
|
|
|
|
|
|
|
|
import aiosqlite
|
2025-11-01 03:30:45 +00:00
|
|
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
2025-11-01 03:21:34 +00:00
|
|
|
|
|
|
|
|
import meals
|
2025-11-01 04:21:01 +00:00
|
|
|
import shopping
|
|
|
|
|
from common import ProblemDetails, ApiModel
|
2025-11-01 03:30:45 +00:00
|
|
|
from api.deps import error_response
|
2025-11-01 03:21:34 +00:00
|
|
|
from api.deps import get_db, get_household_from_slug
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"])
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 04:21:01 +00:00
|
|
|
class MarkConsumedBody(ApiModel):
|
|
|
|
|
consumed_date: Optional[datetime.datetime] = None
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 03:21:34 +00:00
|
|
|
@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[meals.Meal]:
|
|
|
|
|
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 result
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
return result
|
2025-11-01 03:30:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/{meal_id}",
|
|
|
|
|
operation_id="getMealV2",
|
|
|
|
|
summary="Get a meal by id (scoped)",
|
2025-11-01 04:03:27 +00:00
|
|
|
response_model=meals.Meal,
|
2025-11-01 03:30:45 +00:00
|
|
|
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),
|
|
|
|
|
) -> meals.Meal | 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 meal
|
2025-11-01 04:21:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/{meal_id}/consumed",
|
|
|
|
|
operation_id="markMealConsumedV2",
|
|
|
|
|
summary="Mark a meal as consumed (scoped)",
|
|
|
|
|
response_model=meals.Meal,
|
|
|
|
|
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),
|
|
|
|
|
) -> meals.Meal | 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 meal
|