Shopping/Recipes: add scoped DELETE for ad-hoc ingredient requests and “parse-from-url”

This commit is contained in:
jableader 2025-11-01 22:41:39 +11:00
parent 964072391e
commit 5618b6800e
5 changed files with 195 additions and 0 deletions

View file

@ -39,6 +39,10 @@ class RecipeCreate(ApiModel):
)
class ParseUrlIn(ApiModel):
url: str
@router.get("", response_model=Page[RecipeOut])
async def list_recipes(
household=Depends(get_household_from_slug),
@ -212,3 +216,17 @@ async def create_recipe(
created_by_id=r.created_by_id,
created_by=created,
)
@router.post("/parse-from-url")
async def parse_from_url(
body: ParseUrlIn,
household=Depends(get_household_from_slug),
):
# Household dependency enforces access; parsing is stateless
from recipes.scraping import scrape_recipe_ldata
data = await scrape_recipe_ldata(body.url)
if not data:
return error_response(None, 404, "Recipe data not found at URL")
return data

View file

@ -230,6 +230,27 @@ async def request_ingredient_scoped(
return _to_ingredient_item(item)
@router.delete(
"/current/ingredients",
response_model=Ok,
operation_id="unrequestIngredientV2",
summary="Remove an ingredient request (scoped)",
)
async def unrequest_ingredient_scoped(
r: IngredientIdWrapper,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
"""Remove a personal ad-hoc ingredient request for the current user in this household.
Idempotent: returns ok=true whether or not a row was actually deleted.
"""
hid = household["id"]
await shopping.remove_ingredient_request_scoped(conn, r.ingredient_id, user.id, hid)
return Ok()
# Re-export shared DTOs for importers
__all__ = [
"router",

View file

@ -704,6 +704,65 @@
]
}
},
"/api/v1/households/{householdSlug}/recipes/parse-from-url": {
"post": {
"tags": [
"recipes",
"recipes"
],
"summary": "Parse From Url",
"operationId": "parse_from_url_api_v1_households__householdSlug__recipes_parse_from_url_post",
"parameters": [
{
"name": "householdSlug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Householdslug"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ParseUrlIn"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"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": {
"get": {
"tags": [
@ -1476,6 +1535,66 @@
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"shopping",
"shopping"
],
"summary": "Remove an ingredient request (scoped)",
"description": "Remove a personal ad-hoc ingredient request for the current user in this household.\n\nIdempotent: returns ok=true whether or not a row was actually deleted.",
"operationId": "unrequestIngredientV2",
"parameters": [
{
"name": "householdSlug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Householdslug"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IngredientIdWrapper"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ok"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
},
"403": {
"$ref": "#/components/responses/Problem403"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/healthz": {
@ -2356,6 +2475,19 @@
],
"title": "Page[RecipeOut]"
},
"ParseUrlIn": {
"properties": {
"url": {
"type": "string",
"title": "Url"
}
},
"type": "object",
"required": [
"url"
],
"title": "ParseUrlIn"
},
"ProblemDetails": {
"properties": {
"type": {

View file

@ -13,6 +13,7 @@ from shopping.repository import (
request_meal_scoped as request_meal_scoped,
request_ingredient_scoped as request_ingredient_scoped,
remove_meal_request_scoped as remove_meal_request_scoped,
remove_ingredient_request_scoped as remove_ingredient_request_scoped,
load_shopping_list as load_shopping_list,
load_shopping_list_scoped as load_shopping_list_scoped,
purchase as purchase,

View file

@ -380,6 +380,29 @@ async def remove_meal_request_scoped(conn, meal_id: int, household_id: int) -> b
return cursor.rowcount > 0
async def remove_ingredient_request_scoped(
conn, ingredient_id: int, person_id: int, household_id: int
) -> bool:
"""Remove an ad-hoc ingredient request for a specific user within a household.
This targets items that are not yet purchased (list_id IS NULL), have no meal/recipe linkage
(pure personal request), and match the provided ingredient/person/household ids.
"""
async with conn.execute(
"""
DELETE FROM ShoppingListItem
WHERE list_id IS NULL
AND meal_id IS NULL
AND recipe_id IS NULL
AND ingredient_id = ?
AND person_id = ?
AND household_id = ?
""",
(ingredient_id, person_id, household_id),
) as cursor:
return cursor.rowcount > 0
async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]