feat(v2): add household-scoped delete for recipes; tests and OpenAPI updated
This commit is contained in:
parent
ce75603582
commit
b75fee41c6
4 changed files with 131 additions and 1 deletions
|
|
@ -86,6 +86,25 @@ async def get_recipe(
|
||||||
return r
|
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}})
|
@router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}})
|
||||||
async def create_recipe(
|
async def create_recipe(
|
||||||
recipe: RecipeCreate,
|
recipe: RecipeCreate,
|
||||||
|
|
|
||||||
61
openapi.json
61
openapi.json
|
|
@ -1912,6 +1912,67 @@
|
||||||
"bearerAuth": []
|
"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": {
|
"/api/v1/households/{householdSlug}/meals/upcoming": {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
|
||||||
d: dict[str, Any] = {k: v for k, v in col_tuples}
|
d: dict[str, Any] = {k: v for k, v in col_tuples}
|
||||||
img_raw = (
|
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(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||||
WHERE id = ? AND household_id = ?
|
WHERE id = ? AND household_id = ? AND date_hidden IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(recipe_id, household_id),
|
(recipe_id, household_id),
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,37 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
# Get in H2 by id should 404
|
# Get in H2 by id should 404
|
||||||
r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers)
|
r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers)
|
||||||
assert r.status_code == 404
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue