munch-ease-backend/api/meals.py
2025-11-01 12:21:12 +11:00

220 lines
6.5 KiB
Python

from __future__ import annotations
import datetime
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import ingredients
import persons
import shopping
from api.deps import cookie_person, error_response, get_db
from common import ProblemDetails, ApiModel, Field
router = APIRouter(prefix="/meals", tags=["meals"])
class MealOut(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
cleanup: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
consumers: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
recipes: List[meals.MealRecipe] = Field(min_length=0, json_schema_extra={"minItems": 0})
extra_ingredients: List[ingredients.Ingredient] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
purchase_date: Optional[datetime.datetime] = None
@router.get(
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
)
async def get_upcoming_meals(
date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]:
# Load base meals
result: List[meals.Meal] = []
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
result.append(meal)
if not result:
return result
# Batch load participants for all meals
await meals.bulk_load_participants(conn, result)
# Load recipes and extra ingredients per meal (recipes include a small join)
for meal in result:
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
return result
@router.get(
"/{meal_id}",
response_model=MealOut,
operation_id="getMeal",
summary="Get a meal by id",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_meal(
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal | Response:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
return meal
@router.post(
"",
response_model=MealOut,
operation_id="createMeal",
summary="Create a new meal",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
async def create_meal(
meal: meals.Meal,
request: Request,
response: Response,
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.insert_meal(conn, meal)
response.headers["Location"] = f"/api/v1/meals/{meal.id}"
return meal
@router.put(
"/{meal_id}",
response_model=MealOut,
operation_id="updateMeal",
summary="Update an existing meal",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
},
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
},
},
)
async def update_meal(
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal | Response:
if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
existing = await meals.find_meal_by_id(conn, meal_id)
if not existing:
return error_response(request, 404, "Meal not found")
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.update_meal(conn, meal)
# Re-fetch and return the updated meal. Pass request and conn explicitly to avoid Depends resolution.
return await get_meal(meal_id, request, conn)
@router.post(
"/{meal_id}/consumed",
response_model=MealOut,
operation_id="markMealConsumed",
summary="Mark a meal as consumed",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
},
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
},
},
)
async def mark_consumed(
meal_id: int,
request: Request,
consumed_date: Optional[datetime.datetime] = None,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal | Response:
if consumed_date and not consumed_date.tzinfo:
return error_response(request, 400, "Consumed date must include timezone")
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
await shopping.remove_request(conn, person, meal=meal)
return meal
@router.delete(
"/{meal_id}",
response_model=MealOut,
operation_id="deleteMeal",
summary="Delete a meal",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def delete_meal(
meal_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal | Response:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
await shopping.remove_request(conn, person, meal=meal)
await meals.delete_meal(conn, meal.id)
return meal
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Response]:
"""HTTP-friendly wrapper that maps service validation to ProblemDetails."""
msg = meals.validate_meal(meal)
if msg:
return error_response(request, 400, msg)
return None