munch-ease-backend/api/meals.py

179 lines
6.7 KiB
Python
Raw Normal View History

2025-10-18 05:50:43 +00:00
from __future__ import annotations
from typing import List, Optional
import datetime
import aiosqlite
2025-10-18 07:21:04 +00:00
from fastapi import APIRouter, Depends, Query, Request, Response
2025-10-18 05:50:43 +00:00
import meals
import persons
import shopping
from common import ProblemDetails
from api.deps import get_db, cookie_person, error_response
2025-10-18 06:05:48 +00:00
from common import ApiModel, Field
import datetime
from typing import Dict
2025-10-18 05:50:43 +00:00
router = APIRouter(prefix="/meals", tags=["meals"])
@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] = []
2025-10-18 06:05:48 +00:00
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:
2025-10-18 06:05:48 +00:00
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
return result
2025-10-18 05:50:43 +00:00
@router.get("/{meal_id}", response_model=meals.Meal, 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)
2025-10-18 05:50:43 +00:00
) -> meals.Meal:
2025-10-18 06:05:48 +00:00
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
return meal
2025-10-18 05:50:43 +00:00
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
async def create_meal(
2025-10-18 07:21:04 +00:00
meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db)
2025-10-18 05:50:43 +00:00
) -> meals.Meal:
2025-10-18 06:05:48 +00:00
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.insert_meal(conn, meal)
2025-10-18 07:21:04 +00:00
response.headers["Location"] = f"/api/v1/meals/{meal.id}"
2025-10-18 06:05:48 +00:00
return meal
2025-10-18 05:50:43 +00:00
@router.put("/{meal_id}", response_model=meals.Meal, 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)
2025-10-18 05:50:43 +00:00
) -> meals.Meal:
2025-10-18 06:05:48 +00:00
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)
2025-10-18 05:50:43 +00:00
@router.post("/{meal_id}/consumed", response_model=meals.Meal, 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,
2025-10-18 05:50:43 +00:00
consumed_date: Optional[datetime.datetime] = None,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal:
2025-10-18 06:05:48 +00:00
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
2025-10-18 05:50:43 +00:00
@router.delete("/{meal_id}", response_model=meals.Meal, 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,
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal:
2025-10-18 06:05:48 +00:00
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 get_duplicates(items: List[meals.Person]) -> set[str]:
seen: set[int] = set()
duplicates: set[str] = set()
for item in items:
if item.id in seen:
duplicates.add(item.name)
seen.add(item.id)
return duplicates
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Request]:
if not meal.chefs:
return error_response(request, 400, "Meal must have at least one chef")
if not meal.cleanup:
return error_response(request, 400, "Meal must have at least one cleanup person")
if not meal.consumers:
return error_response(request, 400, "Meal must have at least one consumer")
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
return error_response(request, 400, "Meal must have at least one recipe or ingredient")
duplicates = get_duplicates(meal.chefs)
if duplicates:
return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}')
duplicates = get_duplicates(meal.cleanup)
if duplicates:
return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}')
duplicates = get_duplicates(meal.consumers)
if duplicates:
return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}')
zero_servings = [r for r in meal.recipes if r.servings == 0]
if zero_servings:
return error_response(request, 400, "Recipe servings must be greater than 0")
return None