request ingredient

This commit is contained in:
jableader 2025-11-01 18:07:13 +11:00
parent 65d655851d
commit e370ed50dc
7 changed files with 182 additions and 134 deletions

View file

@ -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,
) )

View file

@ -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",

View file

@ -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:

View file

@ -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,
) )

View file

@ -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):

View file

@ -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
for item in shopping_list.items:
item.list_id = shopping_list.id
validate_request(item)
if item.ingredient_id is None or item.ingredient_id < 0:
raise ValueError("Ingredient request must have a valid ingredient id")
isMeal = item.meal_id is not None and item.meal_id >= 0
isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0
if not isMeal and not isPersonRequest:
raise ValueError("Ingredient request must have either a meal or a person 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"
) )
SELECT
elif isMeal: sli.id,
# Insert new request for meal sli.meal_id,
if item.meal_id is None or item.meal_id < 0: sli.ingredient_id,
raise ValueError("Meal request must have a valid meal id") sli.quantity,
sli.unit,
async with conn.execute( sli.added_by_id
""" FROM shopping_list_items sli
INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date, household_id) WHERE
VALUES (?, ?, ?, ?, ?, ?, ?) 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);
""", """,
( {"household_id": household_id},
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 with conn.execute(sql, params) as cursor:
async for row in cursor: async for row in cursor:
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)} yield _to_shopping_list_item(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]:
@ -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"],
)

View file

@ -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):