From b75fee41c62c26c19e861c49ed60f6973a74496b Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 16:01:29 +1100 Subject: [PATCH] feat(v2): add household-scoped delete for recipes; tests and OpenAPI updated --- api/recipes_v2.py | 19 ++++++++++ openapi.json | 61 ++++++++++++++++++++++++++++++ recipes/repository.py | 18 ++++++++- tests/test_recipes_household_v2.py | 34 +++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/api/recipes_v2.py b/api/recipes_v2.py index ea684af..adcd74a 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -86,6 +86,25 @@ async def get_recipe( return r +@router.delete("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) +async def delete_recipe( + recipe_id: int, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +): + # Load recipe in-scope + r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) + if not r: + return error_response(None, 404, "Recipe not found") + # Hide within household (no hidden_by in v2 yet) + from recipes.repository import hide_recipe_scoped + + ok = await hide_recipe_scoped(conn, recipe_id, household["id"]) + if not ok: + return error_response(None, 404, "Recipe not found") + return r + + @router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}}) async def create_recipe( recipe: RecipeCreate, diff --git a/openapi.json b/openapi.json index 899ad5f..33aa887 100644 --- a/openapi.json +++ b/openapi.json @@ -1912,6 +1912,67 @@ "bearerAuth": [] } ] + }, + "delete": { + "tags": [ + "v2", + "recipes-v2" + ], + "summary": "Delete Recipe", + "operationId": "delete_recipe_api_v1_households__householdSlug__recipes__recipe_id__delete", + "parameters": [ + { + "name": "recipe_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recipe Id" + } + }, + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api__recipes_v2__RecipeOut" + } + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/api/v1/households/{householdSlug}/meals/upcoming": { diff --git a/recipes/repository.py b/recipes/repository.py index b4ea23b..c7ba342 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -85,6 +85,22 @@ async def hide_recipe(conn, recipe_id: int, person: Person): ) +async def hide_recipe_scoped(conn, recipe_id: int, household_id: int) -> bool: + """Soft-delete a recipe by household for v2. + + Returns True if updated, False if not found or not in household. + """ + async with conn.execute( + """ + UPDATE Recipe + SET date_hidden = ? + WHERE id = ? AND household_id = ? + """, + (datetime.datetime.now().astimezone().isoformat(), recipe_id, household_id), + ) as cur: + return cur.rowcount > 0 + + def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe: d: dict[str, Any] = {k: v for k, v in col_tuples} img_raw = ( @@ -112,7 +128,7 @@ async def find_recipe_by_id_scoped(conn, recipe_id: int, household_id: int) -> O async with conn.execute( f""" SELECT {",".join(Recipe.KEYS)} FROM Recipe - WHERE id = ? AND household_id = ? + WHERE id = ? AND household_id = ? AND date_hidden IS NULL LIMIT 1 """, (recipe_id, household_id), diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py index 6c19df0..66276e2 100644 --- a/tests/test_recipes_household_v2.py +++ b/tests/test_recipes_household_v2.py @@ -82,3 +82,37 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase): # Get in H2 by id should 404 r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers) assert r.status_code == 404 + + def test_delete_recipe_scoped(self): + # Create a recipe in H1 + recipe = { + "id": -1, + "name": "ToDelete", + "link": "https://example.com/del", + "serves": 2, + "imageUrls": [], + "ingredients": [ + { + "id": 0, + "line": "1 Apple", + "name": "Apple", + "unit": "Items", + "quantity": 1, + "preparation": "", + "product": None, + } + ], + } + r = self.client.post( + f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe + ) + assert r.status_code == 200, r.text + rid = r.json()["id"] + + # Delete it via v2 scoped route + r2 = self.client.delete(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers) + assert r2.status_code == 200, r2.text + + # Subsequent get in same household should be 404 + r3 = self.client.get(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers) + assert r3.status_code == 404