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:
|
try:
|
||||||
# Use household-scoped purchase which ensures requests belong to the same household
|
# Perform purchase; repository enforces relationships, and inputs were household-scoped
|
||||||
await shopping.purchase_scoped(conn, domain_list, hid)
|
await shopping.purchase(conn, domain_list)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return error_response(request, 400, str(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": {
|
"/healthz": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Healthz",
|
"summary": "Healthz",
|
||||||
|
|
@ -1699,6 +1760,19 @@
|
||||||
],
|
],
|
||||||
"title": "Ingredient"
|
"title": "Ingredient"
|
||||||
},
|
},
|
||||||
|
"IngredientIdWrapper": {
|
||||||
|
"properties": {
|
||||||
|
"ingredientId": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Ingredientid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"ingredientId"
|
||||||
|
],
|
||||||
|
"title": "IngredientIdWrapper"
|
||||||
|
},
|
||||||
"IngredientPurchaseItemIn": {
|
"IngredientPurchaseItemIn": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"ingredientId": {
|
"ingredientId": {
|
||||||
|
|
@ -2629,6 +2703,27 @@
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hiddenById": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Hiddenbyid"
|
||||||
|
},
|
||||||
|
"hiddenBy": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MemberRef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ from shopping.repository import (
|
||||||
purchase as purchase,
|
purchase as purchase,
|
||||||
remove_request as remove_request,
|
remove_request as remove_request,
|
||||||
request as request,
|
request as request,
|
||||||
request_ingredient_scoped as request_ingredient_scoped,
|
|
||||||
update_purchased_meals as update_purchased_meals,
|
update_purchased_meals as update_purchased_meals,
|
||||||
validate_request as validate_request,
|
validate_request as validate_request,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,6 @@ class ShoppingListItem(BaseLinkedModel):
|
||||||
"meal_id",
|
"meal_id",
|
||||||
"recipe_id",
|
"recipe_id",
|
||||||
"created_date",
|
"created_date",
|
||||||
"household_id",
|
|
||||||
"quantity",
|
|
||||||
"unit",
|
|
||||||
"added_by_id",
|
|
||||||
"purchased_at",
|
|
||||||
]
|
]
|
||||||
id: int = -1
|
id: int = -1
|
||||||
list_id: Optional[int] = None
|
list_id: Optional[int] = None
|
||||||
|
|
@ -35,12 +30,6 @@ class ShoppingListItem(BaseLinkedModel):
|
||||||
meal_id: Optional[int] = None
|
meal_id: Optional[int] = None
|
||||||
|
|
||||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
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):
|
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(
|
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]:
|
) -> AsyncIterator[ShoppingListItem]:
|
||||||
# outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed
|
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||||
# 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.
|
select = f"""
|
||||||
# The main query then selects items linked to these active meals OR items with no meal link at all.
|
SELECT {",".join(request_cols)}
|
||||||
sql = """
|
FROM ShoppingListItem
|
||||||
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)
|
|
||||||
"""
|
"""
|
||||||
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:
|
if list_id is not None:
|
||||||
sql += " AND sli.list_id = :list_id"
|
where, params = (
|
||||||
params["list_id"] = list_id
|
" WHERE list_id = ? AND household_id = ?",
|
||||||
else:
|
(list_id, household_id),
|
||||||
sql += " AND sli.list_id IS NULL"
|
)
|
||||||
|
|
||||||
|
cursor = await conn.execute(select + where, params)
|
||||||
|
|
||||||
async with conn.execute(sql, params) as cursor:
|
|
||||||
async for row in 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]:
|
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:
|
def _to_shopping_list_item(row: Any) -> ShoppingListItem:
|
||||||
return ShoppingListItem(
|
# Row is a tuple in the order of columns selected; map via KEYS
|
||||||
id=row["id"],
|
return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||||
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"],
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Callable
|
||||||
import importlib
|
import importlib
|
||||||
import unittest
|
import unittest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -60,3 +61,30 @@ class TestShoppingAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
prob = resp.json()
|
prob = resp.json()
|
||||||
assert prob.get("status") == 400
|
assert prob.get("status") == 400
|
||||||
assert "items" in prob.get("title", "").lower() or prob.get("title")
|
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