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: Optional[MemberRef] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[MemberRef] = None
|
||||
|
||||
|
||||
class RecipeCreate(ApiModel):
|
||||
|
|
@ -139,6 +141,7 @@ async def get_recipe(
|
|||
async def delete_recipe(
|
||||
recipe_id: int,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
# Load recipe in-scope
|
||||
|
|
@ -148,6 +151,11 @@ async def delete_recipe(
|
|||
# Hide within household (no hidden_by in v2 yet)
|
||||
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"])
|
||||
if not ok:
|
||||
return error_response(None, 404, "Recipe not found")
|
||||
|
|
@ -159,6 +167,8 @@ async def delete_recipe(
|
|||
row = await c.fetchone()
|
||||
if row:
|
||||
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(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
|
|
@ -168,6 +178,8 @@ async def delete_recipe(
|
|||
ingredients=r.ingredients,
|
||||
created_by_id=r.created_by_id,
|
||||
created_by=mref,
|
||||
hidden_by_id=user.id,
|
||||
hidden_by=hidden,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from api.shopping_models import (
|
|||
ShoppingListOut,
|
||||
PurchasedShoppingList,
|
||||
_to_ingredient_item,
|
||||
ListIngredientItem,
|
||||
_to_meal_item,
|
||||
_to_shopping_list_out,
|
||||
RequestedMealItem,
|
||||
|
|
@ -200,6 +201,37 @@ async def unrequest_meal_scoped(
|
|||
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
|
||||
__all__ = [
|
||||
"router",
|
||||
|
|
|
|||
|
|
@ -279,14 +279,14 @@ Status summary:
|
|||
- 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").
|
||||
- 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):
|
||||
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).
|
||||
- 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.
|
||||
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`.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ from shopping.repository import (
|
|||
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
|
||||
is_requested as is_requested,
|
||||
request_meal_scoped as request_meal_scoped,
|
||||
request_ingredient_scoped as request_ingredient_scoped,
|
||||
remove_meal_request_scoped as remove_meal_request_scoped,
|
||||
load_shopping_list as load_shopping_list,
|
||||
load_shopping_list_scoped as load_shopping_list_scoped,
|
||||
purchase as purchase,
|
||||
purchase_scoped as purchase_scoped,
|
||||
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,6 +17,11 @@ 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
|
||||
|
|
@ -30,6 +35,12 @@ 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -140,116 +140,40 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
|||
await update_purchased_meals(conn, meal_ids)
|
||||
|
||||
|
||||
async def purchase_scoped(conn, shopping_list: ShoppingList, household_id: int) -> None:
|
||||
if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0:
|
||||
raise ValueError("Shopping list must have a person id")
|
||||
|
||||
if shopping_list.items is None or len(shopping_list.items) == 0:
|
||||
raise ValueError("Shopping list must have items")
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
shopping_list.created_date = datetime.now().astimezone()
|
||||
|
||||
async with conn.execute(
|
||||
async def get_outstanding_requests_scoped(conn, household_id: int) -> List[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.
|
||||
rows = await conn.execute_fetchall(
|
||||
"""
|
||||
INSERT INTO ShoppingList (created_date, store_name, purchased_by_id, household_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
shopping_list.created_date.isoformat(),
|
||||
shopping_list.store_name,
|
||||
shopping_list.purchased_by_id,
|
||||
household_id,
|
||||
),
|
||||
) as cursor:
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
FROM shopping_list_items sli
|
||||
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);
|
||||
""",
|
||||
{"household_id": household_id},
|
||||
)
|
||||
# Use scoped purchased ingredient lookup for meal purchase auto-update
|
||||
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)
|
||||
return [_to_shopping_list_item(r) for r in rows]
|
||||
|
||||
|
||||
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||
|
|
@ -368,6 +292,28 @@ async def request_meal_scoped(
|
|||
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(
|
||||
conn,
|
||||
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(
|
||||
conn, list_id: Optional[int], household_id: int
|
||||
conn, list_id: int | None, household_id: int
|
||||
) -> AsyncIterator[ShoppingListItem]:
|
||||
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||
|
||||
select = f"""
|
||||
SELECT {",".join(request_cols)}
|
||||
FROM ShoppingListItem
|
||||
"""
|
||||
|
||||
where: str
|
||||
params: tuple[Any, ...]
|
||||
where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,))
|
||||
if list_id is not None:
|
||||
where, params = (
|
||||
" WHERE list_id = ? AND household_id = ?",
|
||||
(list_id, household_id),
|
||||
# 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)
|
||||
"""
|
||||
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:
|
||||
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
|
||||
request = ShoppingListItem(**request_map)
|
||||
yield request
|
||||
async with conn.execute(sql, params) as cursor:
|
||||
async for row in cursor:
|
||||
yield _to_shopping_list_item(row)
|
||||
|
||||
|
||||
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||
|
|
@ -547,3 +516,18 @@ async def get_purchased_ingredients_scoped(
|
|||
) as cursor:
|
||||
async for row in cursor:
|
||||
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()
|
||||
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
|
||||
def test_create_recipe_requires_ingredients_and_cursor_edge(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue