test_request_ingredient_scoped
This commit is contained in:
parent
e370ed50dc
commit
c4e1293557
6 changed files with 146 additions and 68 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
95
openapi.json
95
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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
cursor = await conn.execute(select + where, params)
|
||||
|
||||
async with conn.execute(sql, params) as cursor:
|
||||
async for row in cursor:
|
||||
yield _to_shopping_list_item(row)
|
||||
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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue