diff --git a/api/recipes.py b/api/recipes.py index 4586c72..7fb6f91 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -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 diff --git a/api/shopping.py b/api/shopping.py index fc9fa41..bcc98b0 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -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", diff --git a/openapi.json b/openapi.json index d9a0127..3236934 100644 --- a/openapi.json +++ b/openapi.json @@ -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": { diff --git a/shopping/__init__.py b/shopping/__init__.py index be00ba3..54cb321 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -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, diff --git a/shopping/repository.py b/shopping/repository.py index 1a1be24..842c90a 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -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]