munch-ease-backend/api/shopping.py
jableader 7b6f4e2a3b Squashed commit of the following:
commit 21a17b771743b23ee41d11a90ed8fdc3433468ce
Author: jableader <jacobdunk@gmail.com>
Date:   Mon Oct 20 00:12:02 2025 +1100

    Completed tooling improvements, fixed remaining errors

commit 7db48e222e3aa1065c326197c33ba6439720f65a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:05:37 2025 +1100

    autoformat

commit 5705ce24b64c2aa6f0b9426730a479165fa97e2a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:05:29 2025 +1100

    tooling changes

commit f0a6b2fd147bb86b484927afd57b9ba0ac07bf47
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 21:25:49 2025 +1100

    Plan
2025-10-20 00:12:16 +11:00

261 lines
8.3 KiB
Python

from __future__ import annotations
from typing import Dict, List
import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
import ingredients
import meals
import persons
import recipes
import shopping
from api.deps import cookie_person, error_response, get_db
from common import ApiModel, Field, ProblemDetails
router = APIRouter(prefix="/shopping", tags=["shopping"])
class CurrentShoppingList(ApiModel):
outstanding_items: List[shopping.ShoppingListItem]
requested_meals: List[shopping.ShoppingListItem]
purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list)
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict)
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
@router.get(
"/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingList",
summary="Get the current aggregated shopping list",
)
async def get_current_shopping_list(
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
(
outstanding_requests,
purchased_requests,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests(conn)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
shopping_list_lookup = {}
for list_id in other_shopping_list_ids:
if list_id is not None:
sl = await shopping.load_shopping_list(conn, list_id)
if sl is not None:
shopping_list_lookup[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
return CurrentShoppingList(
outstanding_items=outstanding_requests,
requested_meals=meal_requests,
purchased_items=purchased_requests,
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
class PurchasedShoppingList(ApiModel):
list: shopping.ShoppingList
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
@router.get(
"/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingList",
summary="Get a purchased shopping list by id",
responses={
404: {
"model": ProblemDetails,
"description": "Shopping list not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_shopping_list(
list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> PurchasedShoppingList | Response:
shopping_list = await shopping.load_shopping_list(conn, list_id)
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=shopping_list,
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredients",
summary="Purchase ingredients for a shopping list",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
},
401: {
"model": ProblemDetails,
"description": "Unauthorized (invalid or unknown user)",
"content": {"application/problem+json": {}},
},
},
)
async def purchase_ingredients(
shopping_list: shopping.ShoppingList,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> PurchasedShoppingList | Response:
# Ensure the caller is authenticated and maps to a known user
if not person:
return error_response(request, 401, "Unauthorized")
# Attach purchaser to ensure purchased_by_id is set via BaseLinkedModel
shopping_list = shopping.ShoppingList(
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
)
try:
await shopping.purchase(conn, shopping_list)
except ValueError as e:
# Map domain validation errors to a proper Problem Details response
return error_response(request, 400, str(e))
result = PurchasedShoppingList(list=shopping_list)
await shopping.to_lookups(
conn,
shopping_list.items,
result.meals_lookup,
result.recipes_lookup,
result.ingredients_lookup,
)
return result
@router.get(
"/current/me/ingredients",
operation_id="getMyShoppingList",
summary="Get my outstanding ingredient requests",
)
async def get_my_shopping_list(
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
) -> List[ingredients.Ingredient]:
return await shopping.get_persons_requests(conn, person.id)
@router.post(
"/current/me/ingredients",
operation_id="syncMyShoppingList",
summary="Sync my outstanding ingredient requests",
)
async def sync_my_shopping_list(
requests: List[ingredients.Ingredient],
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> List[ingredients.Ingredient]:
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
return a.id == b.id or a.line == b.line
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
for r in to_remove:
await shopping.remove_request(conn, person, ingredient=r)
for r in to_add:
if r.id < 0:
await ingredients.insert_ingredient(conn, r)
await shopping.request(conn, person, ingredient=r)
return await get_my_shopping_list(conn, person)
class MealIdWrapper(ApiModel):
meal_id: int
@router.post(
"/current/meals/me",
response_model=shopping.ShoppingListItem,
operation_id="requestMeal",
summary="Request a meal for shopping",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def request_meal(
r: MealIdWrapper,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> shopping.ShoppingListItem | Response:
meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
response = await shopping.request(conn, person, meal=meal)
return response
class Ok(ApiModel):
ok: bool = True
@router.delete(
"/current/meals/{meal_id}",
response_model=Ok,
operation_id="unrequestMeal",
summary="Remove a meal request",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def unrequest_meal(
meal_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> Ok | 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)
return Ok()
# Removed duplicate placeholder endpoints left over from earlier scaffolding