feat(v2): household-scoped shopping current and meals upcoming, with tests; spec updated

This commit is contained in:
jableader 2025-11-01 14:26:23 +11:00
parent e67790c72d
commit 6105d5dadf
6 changed files with 281 additions and 4 deletions

59
api/shopping_v2.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Dict
import aiosqlite
from fastapi import APIRouter, Depends
import shopping
from api.deps import get_db, get_household_from_slug
from api.shopping import CurrentShoppingList, _to_ingredient_item, _to_meal_item, _to_shopping_list_out
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"])
@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,
)

View file

@ -222,7 +222,8 @@ Impact on existing routes (exact files to refactor):
- Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`. - Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`.
- Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS). - Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS).
- ✅ Meals (partial): Added `api/meals_v2.py` with `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`; repository function `find_upcoming_meals_by_date_range_scoped` added. Test `tests/test_meals_household_v2.py` verifies isolation. - ✅ Meals (partial): Added `api/meals_v2.py` with `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`; repository function `find_upcoming_meals_by_date_range_scoped` added. Test `tests/test_meals_household_v2.py` verifies isolation.
- ⏳ Update Repositories: ingredients, shopping, products to accept `household_id` and filter accordingly; extend meals create/update/consumed/delete with scoping. - ✅ Shopping (partial): Added `api/shopping_v2.py` with `/api/v1/households/{householdSlug}/shopping/current`; added scoped helpers in `shopping/repository.py` and `shopping/__init__.py` to filter by `household_id`. Test `tests/test_shopping_household_v2.py` verifies isolation of outstanding items.
- ⏳ Update Repositories: ingredients, products to accept `household_id` and filter accordingly; extend meals create/update/consumed/delete and shopping write flows (purchase, requests) with scoping.
- ⏳ Update Routers: move/duplicate remaining routers under the household router and wire `household_id` through. - ⏳ Update Routers: move/duplicate remaining routers under the household router and wire `household_id` through.
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.

View file

@ -17,6 +17,7 @@ from api import (
recipes as recipes_router, recipes as recipes_router,
recipes_v2 as recipes_v2_router, recipes_v2 as recipes_v2_router,
meals_v2 as meals_v2_router, meals_v2 as meals_v2_router,
shopping_v2 as shopping_v2_router,
shopping as shopping_router, shopping as shopping_router,
households as households_router, households as households_router,
) )
@ -173,6 +174,7 @@ def create_app() -> FastAPI:
pass pass
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["v2"]) # new app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
app.include_router(shopping_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
# Routes # Routes
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)

View file

@ -6,9 +6,12 @@ import recipes
from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem
from shopping.repository import ( from shopping.repository import (
find_items_by_list_id as _find_items_by_list_id, find_items_by_list_id as _find_items_by_list_id,
find_items_by_list_id_scoped as _find_items_by_list_id_scoped,
get_purchased_ingredients as _get_purchased_ingredients, get_purchased_ingredients as _get_purchased_ingredients,
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
is_requested as is_requested, is_requested as is_requested,
load_shopping_list as load_shopping_list, load_shopping_list as load_shopping_list,
load_shopping_list_scoped as load_shopping_list_scoped,
purchase as purchase, purchase as purchase,
remove_request as remove_request, remove_request as remove_request,
request as request, request as request,
@ -138,3 +141,55 @@ async def get_outstanding_requests(
recipes_lookup, recipes_lookup,
ingredients_lookup, ingredients_lookup,
) )
async def get_outstanding_requests_scoped(
conn,
household_id: int,
) -> Tuple[
List[ShoppingListItem],
List[ShoppingListItem],
List[ShoppingListItem],
Dict[int, Any],
Dict[int, Any],
Dict[int, Any],
]:
current_requests = [r async for r in _find_items_by_list_id_scoped(conn, None, household_id)]
meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0]
# Get lookups for meals to enable flattening
meals_lookup, recipes_lookup, ingredients_lookup = await to_lookups(conn, current_requests)
meal_ids = [r.meal_id for r in meal_requests if r.meal_id]
purchased_ingredients = {
(r.ingredient_id, r.meal_id, r.recipe_id): r
async for r in _get_purchased_ingredients_scoped(conn, meal_ids, household_id)
}
outstanding_items = []
purchased_items = []
flattened = list(flatten_items(current_requests, meals_lookup))
# Now ensure that all ingredients from the flattened items are in the lookup
await _ensure_lookups_populated(
conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup
)
for r in flattened:
# Meal ingredients may have already been purchased (by list in the same household)
if r.meal_id is not None and r.meal_id > 0:
purchased_item = purchased_ingredients.get((r.ingredient_id, r.meal_id, r.recipe_id))
if purchased_item:
purchased_items.append(purchased_item)
continue
outstanding_items.append(r)
return (
outstanding_items,
purchased_items,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
)

View file

@ -96,10 +96,10 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
""" """
UPDATE ShoppingListItem UPDATE ShoppingListItem
SET list_id = ? SET list_id = ?
WHERE ingredient_id = ? WHERE ingredient_id = ?
AND list_id IS NULL AND list_id IS NULL
AND person_id = ? AND person_id = ?
AND meal_id IS NULL AND meal_id IS NULL
AND recipe_id IS NULL AND recipe_id IS NULL
""", """,
(shopping_list.id, item.ingredient_id, item.person_id), (shopping_list.id, item.ingredient_id, item.person_id),
@ -270,6 +270,33 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S
yield request yield request
async def find_items_by_list_id_scoped(
conn, list_id: Optional[int], household_id: int
) -> AsyncIterator[ShoppingListItem]:
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
select = f"""
SELECT {",".join(request_cols)}
FROM ShoppingListItem
"""
where: str
params: tuple[Any, ...]
where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,))
if list_id is not None:
where, params = (
" WHERE list_id = ? AND household_id = ?",
(list_id, household_id),
)
cursor = await conn.execute(select + where, params)
async for row in cursor:
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
request = ShoppingListItem(**request_map)
yield request
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]: async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
shopping_list: Optional[ShoppingList] = None shopping_list: Optional[ShoppingList] = None
async with conn.execute( async with conn.execute(
@ -291,6 +318,29 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
return shopping_list return shopping_list
async def load_shopping_list_scoped(
conn, id: int, household_id: int
) -> Optional[ShoppingList]:
shopping_list: Optional[ShoppingList] = None
async with conn.execute(
f"""
SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList
WHERE id = ? AND household_id = ?
LIMIT 1
""",
(id, household_id),
) as cursor:
async for row in cursor:
shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)})
break
if shopping_list:
async for item in find_items_by_list_id_scoped(conn, shopping_list.id, household_id):
shopping_list.items.append(item)
return shopping_list
async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]: async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
if not meal_ids: if not meal_ids:
return return
@ -305,3 +355,21 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[
) as cursor: ) as cursor:
async for row in cursor: async for row in cursor:
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
async def get_purchased_ingredients_scoped(
conn, meal_ids: List[int], household_id: int
) -> AsyncIterator[ShoppingListItem]:
if not meal_ids:
return
async with conn.execute(
f"""
SELECT {",".join(ShoppingListItem.KEYS)}
FROM ShoppingListItem
WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL AND household_id = ?
""",
(*meal_ids, household_id),
) as cursor:
async for row in cursor:
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})

View file

@ -0,0 +1,92 @@
import datetime
import unittest
from fastapi.testclient import TestClient
import main
from db import connect, create
from scripts.migration_to_households import run_migration
class TestShoppingHouseholdV2(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await run_migration(self.conn)
async def override_get_db():
try:
yield self.conn
finally:
pass
main.app.dependency_overrides[main.get_db] = override_get_db
self.client = TestClient(main.app)
# Register a user and create households
r = self.client.post(
"/api/v1/auth/register",
json={"email": "s@test.com", "password": "pw", "displayName": "S"},
)
assert r.status_code == 200, r.text
token = r.json()["accessToken"]
self.headers = {"Authorization": f"Bearer {token}"}
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"})
assert r.status_code == 200, r.text
self.h1 = r.json()["slug"]
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
assert r.status_code == 200, r.text
self.h2 = r.json()["slug"]
# Lookup household ids
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h1,)) as c:
row = await c.fetchone()
assert row is not None
self.h1_id = int(row[0])
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h2,)) as c:
row = await c.fetchone()
assert row is not None
self.h2_id = int(row[0])
# Seed two ingredients and two requests in different households
await self.conn.execute(
"INSERT INTO Ingredient (name, line, unit, quantity, recipe_id, meal_id, household_id) VALUES (?, ?, ?, ?, NULL, NULL, ?)",
("Apple", "1 Apple", "Items", 1, self.h1_id),
)
await self.conn.execute(
"INSERT INTO Ingredient (name, line, unit, quantity, recipe_id, meal_id, household_id) VALUES (?, ?, ?, ?, NULL, NULL, ?)",
("Banana", "2 Banana", "Items", 2, self.h2_id),
)
# Person id 1 is fine for seed (not used functionally here)
now = datetime.datetime.utcnow().isoformat() + "Z"
# Outstanding requests (list_id NULL) in separate households
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, person_id, created_date, household_id) VALUES (1, 1, ?, ?)",
(now, self.h1_id),
)
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, person_id, created_date, household_id) VALUES (2, 1, ?, ?)",
(now, self.h2_id),
)
await self.conn.commit()
async def asyncTearDown(self):
await self.conn.close()
main.app.dependency_overrides.clear()
def test_current_is_scoped(self):
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
assert r1.status_code == 200, r1.text
cur1 = r1.json()
assert len(cur1["outstandingItems"]) == 1
assert cur1["outstandingItems"][0]["ingredientId"] == 1
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
assert r2.status_code == 200, r2.text
cur2 = r2.json()
assert len(cur2["outstandingItems"]) == 1
assert cur2["outstandingItems"][0]["ingredientId"] == 2