request ingredient
This commit is contained in:
parent
65d655851d
commit
e370ed50dc
7 changed files with 182 additions and 134 deletions
|
|
@ -25,6 +25,8 @@ class RecipeOut(ApiModel):
|
||||||
)
|
)
|
||||||
created_by_id: int
|
created_by_id: int
|
||||||
created_by: Optional[MemberRef] = None
|
created_by: Optional[MemberRef] = None
|
||||||
|
hidden_by_id: Optional[int] = None
|
||||||
|
hidden_by: Optional[MemberRef] = None
|
||||||
|
|
||||||
|
|
||||||
class RecipeCreate(ApiModel):
|
class RecipeCreate(ApiModel):
|
||||||
|
|
@ -139,6 +141,7 @@ async def get_recipe(
|
||||||
async def delete_recipe(
|
async def delete_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
household=Depends(get_household_from_slug),
|
household=Depends(get_household_from_slug),
|
||||||
|
user=Depends(get_current_user),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Load recipe in-scope
|
# Load recipe in-scope
|
||||||
|
|
@ -148,6 +151,11 @@ async def delete_recipe(
|
||||||
# Hide within household (no hidden_by in v2 yet)
|
# Hide within household (no hidden_by in v2 yet)
|
||||||
from recipes.repository import hide_recipe_scoped
|
from recipes.repository import hide_recipe_scoped
|
||||||
|
|
||||||
|
# Soft-delete and set hidden_by_id for the record
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE Recipe SET date_hidden = datetime('now'), hidden_by_id = ? WHERE id = ? AND household_id = ?",
|
||||||
|
(user.id, recipe_id, household["id"]),
|
||||||
|
)
|
||||||
ok = await hide_recipe_scoped(conn, recipe_id, household["id"])
|
ok = await hide_recipe_scoped(conn, recipe_id, household["id"])
|
||||||
if not ok:
|
if not ok:
|
||||||
return error_response(None, 404, "Recipe not found")
|
return error_response(None, 404, "Recipe not found")
|
||||||
|
|
@ -159,6 +167,8 @@ async def delete_recipe(
|
||||||
row = await c.fetchone()
|
row = await c.fetchone()
|
||||||
if row:
|
if row:
|
||||||
mref = MemberRef(id=r.created_by_id, display_name=row[0])
|
mref = MemberRef(id=r.created_by_id, display_name=row[0])
|
||||||
|
# hiddenBy is the current user
|
||||||
|
hidden = MemberRef(id=user.id, display_name=user.display_name)
|
||||||
return RecipeOut(
|
return RecipeOut(
|
||||||
id=r.id,
|
id=r.id,
|
||||||
name=r.name,
|
name=r.name,
|
||||||
|
|
@ -168,6 +178,8 @@ async def delete_recipe(
|
||||||
ingredients=r.ingredients,
|
ingredients=r.ingredients,
|
||||||
created_by_id=r.created_by_id,
|
created_by_id=r.created_by_id,
|
||||||
created_by=mref,
|
created_by=mref,
|
||||||
|
hidden_by_id=user.id,
|
||||||
|
hidden_by=hidden,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from api.shopping_models import (
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
PurchasedShoppingList,
|
PurchasedShoppingList,
|
||||||
_to_ingredient_item,
|
_to_ingredient_item,
|
||||||
|
ListIngredientItem,
|
||||||
_to_meal_item,
|
_to_meal_item,
|
||||||
_to_shopping_list_out,
|
_to_shopping_list_out,
|
||||||
RequestedMealItem,
|
RequestedMealItem,
|
||||||
|
|
@ -200,6 +201,37 @@ async def unrequest_meal_scoped(
|
||||||
return Ok()
|
return Ok()
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientIdWrapper(_ApiModel):
|
||||||
|
ingredient_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/current/ingredients",
|
||||||
|
response_model=ListIngredientItem,
|
||||||
|
operation_id="requestIngredientV2",
|
||||||
|
summary="Request an ingredient for shopping (scoped)",
|
||||||
|
)
|
||||||
|
async def request_ingredient_scoped(
|
||||||
|
r: IngredientIdWrapper,
|
||||||
|
request: Request,
|
||||||
|
household=Depends(get_household_from_slug),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
hid = household["id"]
|
||||||
|
from ingredients.repository import find_ingredient_by_id
|
||||||
|
|
||||||
|
ingredient = await find_ingredient_by_id(conn, r.ingredient_id)
|
||||||
|
if not ingredient:
|
||||||
|
return error_response(request, 404, "Ingredient not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
item = await shopping.request_ingredient_scoped(conn, ingredient, hid, user.id)
|
||||||
|
except ValueError as e:
|
||||||
|
return error_response(request, 400, str(e))
|
||||||
|
return _to_ingredient_item(item)
|
||||||
|
|
||||||
|
|
||||||
# Re-export shared DTOs for importers
|
# Re-export shared DTOs for importers
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
|
|
|
||||||
|
|
@ -279,14 +279,14 @@ Status summary:
|
||||||
- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
|
- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
|
||||||
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home").
|
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home").
|
||||||
- Codebase cleanup: v2 routers are inlined as canonical modules; legacy `*_v2.py` files removed. v1 cookie auth and routers are not mounted.
|
- Codebase cleanup: v2 routers are inlined as canonical modules; legacy `*_v2.py` files removed. v1 cookie auth and routers are not mounted.
|
||||||
- Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included in outward Recipe responses. `hiddenBy` will be added when hide flows are wired to users.
|
- Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included. Delete now returns `hiddenById` and `hiddenBy` (MemberRef) for the acting user.
|
||||||
|
|
||||||
Remaining work (prioritized cleanup to final state):
|
Remaining work (prioritized cleanup to final state):
|
||||||
1. Remove the `persons/` package and all code references across domains (recipes, meals, shopping). Replace `Person` with `users`/HouseholdMember everywhere:
|
1. Remove the `persons/` package and all code references across domains (recipes, meals, shopping). Replace `Person` with `users`/HouseholdMember everywhere:
|
||||||
- Code hotspots today: `recipes/models.py` (imports Person), `recipes/repository.py` (FKs, hide_recipe signature), `meals/models.py` (participants as List[Person]), `api/shopping_models.py` and `shopping/models.py` (ShoppingList.purchased_by typed as Person), and `shopping/repository.py` (FKs to Person).
|
- Code hotspots today: `recipes/models.py` (imports Person), `recipes/repository.py` (FKs, hide_recipe signature), `meals/models.py` (participants as List[Person]), `api/shopping_models.py` and `shopping/models.py` (ShoppingList.purchased_by typed as Person), and `shopping/repository.py` (FKs to Person).
|
||||||
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
|
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
|
||||||
- Remove `api.deps.cookie_person` once no tests or code depend on it.
|
- Remove `api.deps.cookie_person` once no tests or code depend on it.
|
||||||
2. Recipes outward schema: DONE for `createdById`/`createdBy` (MemberRef). Remaining: add `hiddenBy` (MemberRef) when hide flows are wired to users.
|
2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef.
|
||||||
3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`.
|
3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`.
|
||||||
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers.
|
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers.
|
||||||
5. Database polish:
|
5. Database polish:
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ from shopping.repository import (
|
||||||
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
|
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
|
||||||
is_requested as is_requested,
|
is_requested as is_requested,
|
||||||
request_meal_scoped as request_meal_scoped,
|
request_meal_scoped as request_meal_scoped,
|
||||||
|
request_ingredient_scoped as request_ingredient_scoped,
|
||||||
remove_meal_request_scoped as remove_meal_request_scoped,
|
remove_meal_request_scoped as remove_meal_request_scoped,
|
||||||
load_shopping_list as load_shopping_list,
|
load_shopping_list as load_shopping_list,
|
||||||
load_shopping_list_scoped as load_shopping_list_scoped,
|
load_shopping_list_scoped as load_shopping_list_scoped,
|
||||||
purchase as purchase,
|
purchase as purchase,
|
||||||
purchase_scoped as purchase_scoped,
|
|
||||||
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,6 +17,11 @@ 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
|
||||||
|
|
@ -30,6 +35,12 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, AsyncIterator, List, Optional
|
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||||
|
|
||||||
from shopping.models import ShoppingList, ShoppingListItem
|
from shopping.models import ShoppingList, ShoppingListItem
|
||||||
|
|
||||||
|
|
@ -140,116 +140,40 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
||||||
await update_purchased_meals(conn, meal_ids)
|
await update_purchased_meals(conn, meal_ids)
|
||||||
|
|
||||||
|
|
||||||
async def purchase_scoped(conn, shopping_list: ShoppingList, household_id: int) -> None:
|
async def get_outstanding_requests_scoped(conn, household_id: int) -> List[ShoppingListItem]:
|
||||||
if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0:
|
# outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed
|
||||||
raise ValueError("Shopping list must have a person id")
|
# 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.
|
||||||
if shopping_list.items is None or len(shopping_list.items) == 0:
|
# The main query then selects items linked to these active meals OR items with no meal link at all.
|
||||||
raise ValueError("Shopping list must have items")
|
rows = await conn.execute_fetchall(
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
shopping_list.created_date = datetime.now().astimezone()
|
|
||||||
|
|
||||||
async with conn.execute(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO ShoppingList (created_date, store_name, purchased_by_id, household_id)
|
WITH active_meal_ids AS (
|
||||||
VALUES (?, ?, ?, ?)
|
SELECT m.id
|
||||||
""",
|
FROM meals m
|
||||||
(
|
LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id
|
||||||
shopping_list.created_date.isoformat(),
|
LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id
|
||||||
shopping_list.store_name,
|
WHERE
|
||||||
shopping_list.purchased_by_id,
|
m.household_id = :household_id
|
||||||
household_id,
|
AND m.consumed_date IS NULL
|
||||||
),
|
AND (sl.id IS NULL OR sl.purchased_by_id IS NULL)
|
||||||
) as cursor:
|
GROUP BY m.id
|
||||||
shopping_list.id = cursor.lastrowid
|
)
|
||||||
|
SELECT
|
||||||
for item in shopping_list.items:
|
sli.id,
|
||||||
item.list_id = shopping_list.id
|
sli.meal_id,
|
||||||
validate_request(item)
|
sli.ingredient_id,
|
||||||
|
sli.quantity,
|
||||||
if item.ingredient_id is None or item.ingredient_id < 0:
|
sli.unit,
|
||||||
raise ValueError("Ingredient request must have a valid ingredient id")
|
sli.added_by_id
|
||||||
|
FROM shopping_list_items sli
|
||||||
isMeal = item.meal_id is not None and item.meal_id >= 0
|
WHERE
|
||||||
isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0
|
sli.household_id = :household_id
|
||||||
|
AND sli.purchased_at IS NULL
|
||||||
if not isMeal and not isPersonRequest:
|
AND (sli.meal_id IN (SELECT id FROM active_meal_ids) OR sli.meal_id IS NULL);
|
||||||
raise ValueError("Ingredient request must have either a meal or a person id")
|
""",
|
||||||
|
{"household_id": household_id},
|
||||||
if isPersonRequest:
|
|
||||||
# Update existing request from its null id, scoping by household
|
|
||||||
async with conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE ShoppingListItem
|
|
||||||
SET list_id = ?
|
|
||||||
WHERE ingredient_id = ?
|
|
||||||
AND list_id IS NULL
|
|
||||||
AND person_id = ?
|
|
||||||
AND meal_id IS NULL
|
|
||||||
AND recipe_id IS NULL
|
|
||||||
AND household_id = ?
|
|
||||||
""",
|
|
||||||
(shopping_list.id, item.ingredient_id, item.person_id, household_id),
|
|
||||||
) as cursor:
|
|
||||||
if cursor.rowcount == 0:
|
|
||||||
raise ValueError(
|
|
||||||
"Ingredient request must have a valid person id and ingredient id"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif isMeal:
|
|
||||||
# Insert new request for meal
|
|
||||||
if item.meal_id is None or item.meal_id < 0:
|
|
||||||
raise ValueError("Meal request must have a valid meal id")
|
|
||||||
|
|
||||||
async with conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date, household_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
item.ingredient_id,
|
|
||||||
shopping_list.id,
|
|
||||||
item.person_id,
|
|
||||||
item.meal_id,
|
|
||||||
item.recipe_id,
|
|
||||||
item.created_date.isoformat(),
|
|
||||||
household_id,
|
|
||||||
),
|
|
||||||
) as cursor:
|
|
||||||
item.id = cursor.lastrowid
|
|
||||||
|
|
||||||
meal_ids = list(
|
|
||||||
{
|
|
||||||
item.meal_id
|
|
||||||
for item in shopping_list.items
|
|
||||||
if item.meal_id is not None and item.meal_id >= 0
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
# Use scoped purchased ingredient lookup for meal purchase auto-update
|
return [_to_shopping_list_item(r) for r in rows]
|
||||||
if meal_ids:
|
|
||||||
# Mark purchased if all ingredients covered in household
|
|
||||||
from meals.repository import find_meal_by_id, mark_purchased
|
|
||||||
|
|
||||||
purchased_ingredient_ids = {
|
|
||||||
item.ingredient_id
|
|
||||||
async for item in get_purchased_ingredients_scoped(conn, meal_ids, household_id)
|
|
||||||
}
|
|
||||||
for meal_id in meal_ids:
|
|
||||||
meal = await find_meal_by_id(conn, meal_id)
|
|
||||||
if not meal:
|
|
||||||
continue
|
|
||||||
ingredients = {
|
|
||||||
ingredient.id
|
|
||||||
for mr in meal.recipes
|
|
||||||
for ingredient in (mr.recipe.ingredients if mr.recipe else [])
|
|
||||||
} | {ingredient.id for ingredient in meal.extra_ingredients}
|
|
||||||
|
|
||||||
remaining_ingredients = ingredients - purchased_ingredient_ids
|
|
||||||
if not remaining_ingredients:
|
|
||||||
await mark_purchased(conn, meal)
|
|
||||||
await remove_request(conn, person=None, meal=meal)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||||
|
|
@ -368,6 +292,28 @@ async def request_meal_scoped(
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def request_ingredient_scoped(
|
||||||
|
conn, ingredient: Any, household_id: int, person_id: int
|
||||||
|
) -> ShoppingListItem:
|
||||||
|
if ingredient is None or getattr(ingredient, "id", -1) < 0:
|
||||||
|
raise ValueError("Ingredient must have a valid id")
|
||||||
|
|
||||||
|
# TODO: Check if ingredient is already requested by this person
|
||||||
|
|
||||||
|
item = ShoppingListItem(ingredient_id=ingredient.id, person_id=person_id, meal_id=None)
|
||||||
|
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(ingredient.id, person_id, None, item.created_date.isoformat(), household_id),
|
||||||
|
) as cursor:
|
||||||
|
item.id = cursor.lastrowid
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
async def remove_request(
|
async def remove_request(
|
||||||
conn,
|
conn,
|
||||||
person: Optional[Any] = None,
|
person: Optional[Any] = None,
|
||||||
|
|
@ -431,30 +377,53 @@ 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: Optional[int], household_id: int
|
conn, list_id: int | None, household_id: int
|
||||||
) -> AsyncIterator[ShoppingListItem]:
|
) -> AsyncIterator[ShoppingListItem]:
|
||||||
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
# 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.
|
||||||
select = f"""
|
# The subquery for `active_meal_ids` finds meals that are not consumed and not part of a purchased list.
|
||||||
SELECT {",".join(request_cols)}
|
# The main query then selects items linked to these active meals OR items with no meal link at all.
|
||||||
FROM ShoppingListItem
|
sql = """
|
||||||
"""
|
WITH active_meal_ids AS (
|
||||||
|
SELECT m.id
|
||||||
where: str
|
FROM meals m
|
||||||
params: tuple[Any, ...]
|
LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id
|
||||||
where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,))
|
LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id
|
||||||
if list_id is not None:
|
WHERE
|
||||||
where, params = (
|
m.household_id = :household_id
|
||||||
" WHERE list_id = ? AND household_id = ?",
|
AND m.consumed_date IS NULL
|
||||||
(list_id, household_id),
|
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}
|
||||||
|
|
||||||
cursor = await conn.execute(select + where, params)
|
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"
|
||||||
|
|
||||||
async for row in cursor:
|
async with conn.execute(sql, params) as cursor:
|
||||||
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
|
async for row in cursor:
|
||||||
request = ShoppingListItem(**request_map)
|
yield _to_shopping_list_item(row)
|
||||||
yield request
|
|
||||||
|
|
||||||
|
|
||||||
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||||
|
|
@ -547,3 +516,18 @@ async def get_purchased_ingredients_scoped(
|
||||||
) 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)})
|
||||||
|
|
||||||
|
|
||||||
|
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"],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -121,11 +121,19 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
assert "createdBy" in r.json() and "createdById" in r.json()
|
assert "createdBy" in r.json() and "createdById" in r.json()
|
||||||
|
|
||||||
# Delete it via v2 scoped route
|
# Delete it via v2 scoped route
|
||||||
r2 = self.client.delete(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers)
|
r2 = self.client.delete(
|
||||||
|
f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers
|
||||||
|
)
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
|
# hiddenBy is populated on delete
|
||||||
|
body_del = r2.json()
|
||||||
|
assert "hiddenBy" in body_del and "hiddenById" in body_del
|
||||||
|
assert body_del["hiddenBy"]["displayName"] == "R"
|
||||||
|
|
||||||
# Subsequent get in same household should be 404
|
# Subsequent get in same household should be 404
|
||||||
r3 = self.client.get(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers)
|
r3 = self.client.get(
|
||||||
|
f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers
|
||||||
|
)
|
||||||
assert r3.status_code == 404
|
assert r3.status_code == 404
|
||||||
|
|
||||||
def test_create_recipe_requires_ingredients_and_cursor_edge(self):
|
def test_create_recipe_requires_ingredients_and_cursor_edge(self):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue