220 lines
6.5 KiB
Python
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
|