diff --git a/api/recipes_v2.py b/api/recipes_v2.py index 26fb68b..b32182f 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -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, ) diff --git a/api/shopping.py b/api/shopping.py index 7cd4ffb..1002912 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -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", diff --git a/backend-spec.md b/backend-spec.md index 708eac6..f2b1fe3 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -279,14 +279,14 @@ Status summary: - New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }]. - Preserved: camelCase responses, `Page` 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: diff --git a/shopping/__init__.py b/shopping/__init__.py index 81b86d8..d509eb3 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -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, ) diff --git a/shopping/models.py b/shopping/models.py index 7e20640..779306b 100644 --- a/shopping/models.py +++ b/shopping/models.py @@ -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): diff --git a/shopping/repository.py b/shopping/repository.py index 7588b9e..a78a4d6 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -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"], + ) diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py index 38c1797..ed6f5c8 100644 --- a/tests/test_recipes_household_v2.py +++ b/tests/test_recipes_household_v2.py @@ -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):