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
261 lines
8.3 KiB
Python
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
|