munch-ease-backend/api/shopping.py

260 lines
8 KiB
Python

from __future__ import annotations
from typing import Dict, List
import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
import shopping
from common import ApiModel as _ApiModel
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
from api.shopping_models import (
CurrentShoppingList,
ShoppingListOut,
PurchasedShoppingList,
_to_ingredient_item,
ListIngredientItem,
_to_meal_item,
_to_shopping_list_out,
RequestedMealItem,
PurchaseListIn,
)
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping"])
@router.get(
"/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingListV2",
summary="Get the current aggregated shopping list (scoped)",
)
async def get_current_shopping_list_scoped(
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
hid = household["id"]
(
outstanding_requests,
purchased_requests,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests_scoped(conn, hid)
# Load full lists for additional lookups (by household)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
for list_id in other_shopping_list_ids:
if list_id is not None:
sl = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if sl is not None:
other_lists_domain[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
return CurrentShoppingList(
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
requested_meals=[_to_meal_item(i) for i in meal_requests],
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
@router.get(
"/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingListV2",
summary="Get a purchased shopping list by id (scoped)",
)
async def get_shopping_list_scoped(
list_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
shopping_list = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredientsV2",
summary="Purchase ingredients for a shopping list (scoped)",
)
async def purchase_ingredients_scoped(
shopping_list: PurchaseListIn,
request: Request,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or __import__("datetime").datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(
items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id
)
try:
# Perform purchase; repository enforces relationships, and inputs were household-scoped
await shopping.purchase(conn, domain_list)
except ValueError as e:
return error_response(request, 400, str(e))
# Lookups for outward response
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, domain_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(domain_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
class MealIdWrapper(_ApiModel):
meal_id: int
class Ok(_ApiModel):
ok: bool = True
@router.post(
"/current/meals/me",
response_model=RequestedMealItem,
operation_id="requestMealV2",
summary="Request a meal for shopping (scoped)",
)
async def request_meal_scoped(
r: MealIdWrapper,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
from meals.repository import find_meal_by_id_scoped
meal = await find_meal_by_id_scoped(conn, r.meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
try:
item = await shopping.request_meal_scoped(conn, meal, hid)
except ValueError as e:
return error_response(request, 400, str(e))
return _to_meal_item(item)
@router.delete(
"/current/meals/{meal_id}",
response_model=Ok,
operation_id="unrequestMealV2",
summary="Remove a meal request (scoped)",
)
async def unrequest_meal_scoped(
meal_id: int,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
await shopping.remove_meal_request_scoped(conn, meal_id, hid)
return Ok()
class IngredientIdWrapper(_ApiModel):
ingredient_id: int
@router.post(
"/current/ingredients",
response_model=ListIngredientItem,
operation_id="requestIngredientV2",
summary="Request an ingredient for shopping (scoped)",
)
async def request_ingredient_scoped(
r: IngredientIdWrapper,
request: Request,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
from ingredients.repository import find_ingredient_by_id
ingredient = await find_ingredient_by_id(conn, r.ingredient_id)
if not ingredient:
return error_response(request, 404, "Ingredient not found")
try:
item = await shopping.request_ingredient_scoped(conn, ingredient, hid, user.id)
except ValueError as e:
return error_response(request, 400, str(e))
return _to_ingredient_item(item)
@router.delete(
"/current/ingredients",
response_model=Ok,
operation_id="unrequestIngredientV2",
summary="Remove an ingredient request (scoped)",
)
async def unrequest_ingredient_scoped(
r: IngredientIdWrapper,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
"""Remove a personal ad-hoc ingredient request for the current user in this household.
Idempotent: returns ok=true whether or not a row was actually deleted.
"""
hid = household["id"]
await shopping.remove_ingredient_request_scoped(conn, r.ingredient_id, user.id, hid)
return Ok()
# Re-export shared DTOs for importers
__all__ = [
"router",
"CurrentShoppingList",
"ShoppingListOut",
"PurchasedShoppingList",
]