feat(v2): household-scoped shopping current and meals upcoming, with tests; spec updated
This commit is contained in:
parent
e67790c72d
commit
6105d5dadf
6 changed files with 281 additions and 4 deletions
59
api/shopping_v2.py
Normal file
59
api/shopping_v2.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)})
|
||||||
|
|
|
||||||
92
tests/test_shopping_household_v2.py
Normal file
92
tests/test_shopping_household_v2.py
Normal 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
|
||||||
Loading…
Reference in a new issue