From 6105d5dadf5bb60829ecc0d050d4474d0f7c4a9b Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 14:26:23 +1100 Subject: [PATCH] feat(v2): household-scoped shopping current and meals upcoming, with tests; spec updated --- api/shopping_v2.py | 59 ++++++++++++++++++ backend-spec.md | 3 +- main.py | 2 + shopping/__init__.py | 55 +++++++++++++++++ shopping/repository.py | 74 ++++++++++++++++++++++- tests/test_shopping_household_v2.py | 92 +++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 api/shopping_v2.py create mode 100644 tests/test_shopping_household_v2.py diff --git a/api/shopping_v2.py b/api/shopping_v2.py new file mode 100644 index 0000000..27f29ad --- /dev/null +++ b/api/shopping_v2.py @@ -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, + ) diff --git a/backend-spec.md b/backend-spec.md index 0ea4bbf..4a2e100 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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`. - 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. - - ⏳ 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. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. diff --git a/main.py b/main.py index c5ecea2..cc13214 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ from api import ( recipes as recipes_router, recipes_v2 as recipes_v2_router, meals_v2 as meals_v2_router, + shopping_v2 as shopping_v2_router, shopping as shopping_router, households as households_router, ) @@ -173,6 +174,7 @@ def create_app() -> FastAPI: pass 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(shopping_v2_router.router, prefix="/api/v1", tags=["v2"]) # new # Routes app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) diff --git a/shopping/__init__.py b/shopping/__init__.py index 3a911b9..1533287 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -6,9 +6,12 @@ import recipes from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem from shopping.repository import ( 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_scoped as _get_purchased_ingredients_scoped, is_requested as is_requested, load_shopping_list as load_shopping_list, + load_shopping_list_scoped as load_shopping_list_scoped, purchase as purchase, remove_request as remove_request, request as request, @@ -138,3 +141,55 @@ async def get_outstanding_requests( recipes_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, + ) diff --git a/shopping/repository.py b/shopping/repository.py index b8aeb58..cf0f813 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -96,10 +96,10 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: """ UPDATE ShoppingListItem SET list_id = ? - WHERE ingredient_id = ? - AND list_id IS NULL + WHERE ingredient_id = ? + AND list_id IS NULL AND person_id = ? - AND meal_id IS NULL + AND meal_id IS NULL AND recipe_id IS NULL """, (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 +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]: shopping_list: Optional[ShoppingList] = None async with conn.execute( @@ -291,6 +318,29 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]: 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]: if not meal_ids: return @@ -305,3 +355,21 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ ) as cursor: async for row in cursor: 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)}) diff --git a/tests/test_shopping_household_v2.py b/tests/test_shopping_household_v2.py new file mode 100644 index 0000000..7806329 --- /dev/null +++ b/tests/test_shopping_household_v2.py @@ -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