diff --git a/api/shopping.py b/api/shopping.py index 1002912..f04f730 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -134,8 +134,8 @@ async def purchase_ingredients_scoped( ) try: - # Use household-scoped purchase which ensures requests belong to the same household - await shopping.purchase_scoped(conn, domain_list, hid) + # Perform purchase; repository enforces relationships, and inputs were household-scoped + await shopping.purchase(conn, domain_list) except ValueError as e: return error_response(request, 400, str(e)) diff --git a/openapi.json b/openapi.json index 719bab8..4766dda 100644 --- a/openapi.json +++ b/openapi.json @@ -1416,6 +1416,67 @@ ] } }, + "/api/v1/households/{householdSlug}/shopping/current/ingredients": { + "post": { + "tags": [ + "shopping", + "shopping" + ], + "summary": "Request an ingredient for shopping (scoped)", + "operationId": "requestIngredientV2", + "parameters": [ + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngredientIdWrapper" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListIngredientItem" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/healthz": { "get": { "summary": "Healthz", @@ -1699,6 +1760,19 @@ ], "title": "Ingredient" }, + "IngredientIdWrapper": { + "properties": { + "ingredientId": { + "type": "integer", + "title": "Ingredientid" + } + }, + "type": "object", + "required": [ + "ingredientId" + ], + "title": "IngredientIdWrapper" + }, "IngredientPurchaseItemIn": { "properties": { "ingredientId": { @@ -2629,6 +2703,27 @@ "type": "null" } ] + }, + "hiddenById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hiddenbyid" + }, + "hiddenBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberRef" + }, + { + "type": "null" + } + ] } }, "type": "object", diff --git a/shopping/__init__.py b/shopping/__init__.py index d509eb3..be00ba3 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -18,7 +18,6 @@ from shopping.repository import ( purchase as purchase, remove_request as remove_request, request as request, - request_ingredient_scoped as request_ingredient_scoped, update_purchased_meals as update_purchased_meals, validate_request as validate_request, ) diff --git a/shopping/models.py b/shopping/models.py index 779306b..7e20640 100644 --- a/shopping/models.py +++ b/shopping/models.py @@ -17,11 +17,6 @@ class ShoppingListItem(BaseLinkedModel): "meal_id", "recipe_id", "created_date", - "household_id", - "quantity", - "unit", - "added_by_id", - "purchased_at", ] id: int = -1 list_id: Optional[int] = None @@ -35,12 +30,6 @@ class ShoppingListItem(BaseLinkedModel): meal_id: Optional[int] = None created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone()) - household_id: int | None = None - quantity: float | None = None - unit: str | None = None - added_by_id: int | None = None - purchased_at: datetime | None = None - added_by_name: str | None = None class StoreEnum(str, Enum): diff --git a/shopping/repository.py b/shopping/repository.py index a78a4d6..e290402 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -377,53 +377,30 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S async def find_items_by_list_id_scoped( - conn, list_id: int | None, household_id: int + conn, list_id: Optional[int], household_id: int ) -> AsyncIterator[ShoppingListItem]: - # outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed - # and is not part of a shopping list that has been purchased. - # The subquery for `active_meal_ids` finds meals that are not consumed and not part of a purchased list. - # The main query then selects items linked to these active meals OR items with no meal link at all. - sql = """ - WITH active_meal_ids AS ( - SELECT m.id - FROM meals m - LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id - LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id - WHERE - m.household_id = :household_id - AND m.consumed_date IS NULL - AND (sl.id IS NULL OR sl.purchased_by_id IS NULL) - GROUP BY m.id - ) - SELECT - sli.id, - sli.meal_id, - sli.ingredient_id, - sli.quantity, - sli.unit, - sli.added_by_id, - sli.list_id, - sli.purchased_at, - sli.recipe_id, - u.display_name as added_by_name - FROM shopping_list_items sli - JOIN users u on u.id = sli.added_by_id - WHERE - sli.household_id = :household_id - AND sli.purchased_at IS NULL - AND (sli.meal_id IN (SELECT id FROM active_meal_ids) OR sli.meal_id IS NULL) + request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] + + select = f""" + SELECT {",".join(request_cols)} + FROM ShoppingListItem """ - params: Dict[str, Any] = {"household_id": household_id} + where: str + params: tuple[Any, ...] + where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,)) if list_id is not None: - sql += " AND sli.list_id = :list_id" - params["list_id"] = list_id - else: - sql += " AND sli.list_id IS NULL" + where, params = ( + " WHERE list_id = ? AND household_id = ?", + (list_id, household_id), + ) - async with conn.execute(sql, params) as cursor: - async for row in cursor: - yield _to_shopping_list_item(row) + 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]: @@ -519,15 +496,5 @@ async def get_purchased_ingredients_scoped( def _to_shopping_list_item(row: Any) -> ShoppingListItem: - return ShoppingListItem( - id=row["id"], - meal_id=row["meal_id"], - ingredient_id=row["ingredient_id"], - quantity=row["quantity"], - unit=row["unit"], - added_by_id=row["added_by_id"], - list_id=row["list_id"], - purchased_at=row["purchased_at"], - recipe_id=row["recipe_id"], - added_by_name=row["added_by_name"], - ) + # Row is a tuple in the order of columns selected; map via KEYS + return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) diff --git a/tests/test_shopping_api.py b/tests/test_shopping_api.py index 9589076..2fd2d11 100644 --- a/tests/test_shopping_api.py +++ b/tests/test_shopping_api.py @@ -1,3 +1,4 @@ +from typing import Callable import importlib import unittest from fastapi.testclient import TestClient @@ -60,3 +61,30 @@ class TestShoppingAPI(unittest.IsolatedAsyncioTestCase): prob = resp.json() assert prob.get("status") == 400 assert "items" in prob.get("title", "").lower() or prob.get("title") + + def test_request_ingredient_scoped( + self, + household_and_user: dict, + auth_headers: dict, + ingredient_factory: Callable, + ): + slug = household_and_user["household"]["slug"] + ingredient = ingredient_factory() + + # Request an ingredient + response = self.client.post( + f"/households/{slug}/shopping/current/ingredients", + headers=auth_headers, + json={"ingredientId": ingredient.id}, + ) + assert response.status_code == 200 + requested_item = response.json() + assert requested_item["ingredientId"] == ingredient.id + assert requested_item["mealId"] is None + + # Check that it appears in the outstanding list + response = self.client.get(f"/households/{slug}/shopping/current", headers=auth_headers) + assert response.status_code == 200 + current_list = response.json() + assert len(current_list["outstandingItems"]) == 1 + assert current_list["outstandingItems"][0]["ingredientId"] == ingredient.id