mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Use Backend for Recipe Post Actions (#4163)
This commit is contained in:
		| @@ -117,10 +117,10 @@ Unlike notifiers, which are event-driven notifications, Webhooks allow you to se | |||||||
|  |  | ||||||
| Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions: | Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions: | ||||||
|  |  | ||||||
| 1. link - these actions will take you directly to an external page | 1. link - these actions will take you directly to an external page. Merge fields can be used to customize the URL for each recipe | ||||||
| 2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant | 2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant | ||||||
|  |  | ||||||
| Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug: | When using the "link" action type, Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug: | ||||||
| ``` | ``` | ||||||
| https://www.google.com/search?q=${slug} | https://www.google.com/search?q=${slug} | ||||||
| ``` | ``` | ||||||
|   | |||||||
| @@ -376,7 +376,7 @@ export default defineComponent({ | |||||||
|       const response = await groupRecipeActionsStore.execute(action, props.recipe); |       const response = await groupRecipeActionsStore.execute(action, props.recipe); | ||||||
|  |  | ||||||
|       if (action.actionType === "post") { |       if (action.actionType === "post") { | ||||||
|         if (!response || (response.status >= 200  && response.status < 300)) { |         if (!response?.error) { | ||||||
|           alert.success(i18n.tc("events.message-sent")); |           alert.success(i18n.tc("events.message-sent")); | ||||||
|         } else { |         } else { | ||||||
|           alert.error(i18n.tc("events.something-went-wrong")); |           alert.error(i18n.tc("events.something-went-wrong")); | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import { computed, reactive, ref } from "@nuxtjs/composition-api"; | |||||||
| import { useStoreActions } from "./partials/use-actions-factory"; | import { useStoreActions } from "./partials/use-actions-factory"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household"; | import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household"; | ||||||
|  | import { RequestResponse } from "~/lib/api/types/non-generated"; | ||||||
| import { Recipe } from "~/lib/api/types/recipe"; | import { Recipe } from "~/lib/api/types/recipe"; | ||||||
|  |  | ||||||
| const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null); | const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null); | ||||||
| @@ -54,26 +55,15 @@ export const useGroupRecipeActions = function ( | |||||||
|     /* eslint-enable no-template-curly-in-string */ |     /* eslint-enable no-template-curly-in-string */ | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | Response> { |   async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | RequestResponse<unknown>> { | ||||||
|     const url = parseRecipeActionUrl(action.url, recipe); |     const url = parseRecipeActionUrl(action.url, recipe); | ||||||
|  |  | ||||||
|     switch (action.actionType) { |     switch (action.actionType) { | ||||||
|       case "link": |       case "link": | ||||||
|         window.open(url, "_blank")?.focus(); |         window.open(url, "_blank")?.focus(); | ||||||
|         break; |         return; | ||||||
|       case "post": |       case "post": | ||||||
|         return await fetch(url, { |         return await api.groupRecipeActions.triggerAction(action.id, recipe.slug || ""); | ||||||
|           method: "POST", |  | ||||||
|           headers: { |  | ||||||
|             // The "text/plain" content type header is used here to skip the CORS preflight request, |  | ||||||
|             // since it may fail. This is fine, since we don't care about the response, we just want |  | ||||||
|             // the request to get sent. |  | ||||||
|             "Content-Type": "text/plain", |  | ||||||
|           }, |  | ||||||
|           body: JSON.stringify(recipe), |  | ||||||
|         }).catch((error) => { |  | ||||||
|           console.error(error); |  | ||||||
|         }); |  | ||||||
|       default: |       default: | ||||||
|         break; |         break; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -165,6 +165,10 @@ export interface GroupRecipeActionOut { | |||||||
|   householdId: string; |   householdId: string; | ||||||
|   id: string; |   id: string; | ||||||
| } | } | ||||||
|  | export interface GroupRecipeActionPayload { | ||||||
|  |   action: GroupRecipeActionOut; | ||||||
|  |   content: unknown; | ||||||
|  | } | ||||||
| export interface HouseholdCreate { | export interface HouseholdCreate { | ||||||
|   groupId?: string | null; |   groupId?: string | null; | ||||||
|   name: string; |   name: string; | ||||||
|   | |||||||
| @@ -6,9 +6,14 @@ const prefix = "/api"; | |||||||
| const routes = { | const routes = { | ||||||
|     groupRecipeActions: `${prefix}/households/recipe-actions`, |     groupRecipeActions: `${prefix}/households/recipe-actions`, | ||||||
|     groupRecipeActionsId: (id: string | number) => `${prefix}/households/recipe-actions/${id}`, |     groupRecipeActionsId: (id: string | number) => `${prefix}/households/recipe-actions/${id}`, | ||||||
|  |     groupRecipeActionsIdTriggerRecipeSlug: (id: string | number, recipeSlug: string) => `${prefix}/households/recipe-actions/${id}/trigger/${recipeSlug}`, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> { |   export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> { | ||||||
|     baseRoute = routes.groupRecipeActions; |     baseRoute = routes.groupRecipeActions; | ||||||
|     itemRoute = routes.groupRecipeActionsId; |     itemRoute = routes.groupRecipeActionsId; | ||||||
|  |  | ||||||
|  |     async triggerAction(id: string | number, recipeSlug: string) { | ||||||
|  |       return await this.requests.post(routes.groupRecipeActionsIdTriggerRecipeSlug(id, recipeSlug), {}); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,8 +1,11 @@ | |||||||
| from functools import cached_property | from functools import cached_property | ||||||
|  |  | ||||||
| from fastapi import APIRouter, Depends | import requests | ||||||
|  | from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status | ||||||
|  | from fastapi.encoders import jsonable_encoder | ||||||
| from pydantic import UUID4 | from pydantic import UUID4 | ||||||
|  |  | ||||||
|  | from mealie.core.exceptions import NoEntryFound | ||||||
| from mealie.routes._base.base_controllers import BaseUserController | from mealie.routes._base.base_controllers import BaseUserController | ||||||
| from mealie.routes._base.controller import controller | from mealie.routes._base.controller import controller | ||||||
| from mealie.routes._base.mixins import HttpRepo | from mealie.routes._base.mixins import HttpRepo | ||||||
| @@ -10,9 +13,13 @@ from mealie.schema.household.group_recipe_action import ( | |||||||
|     CreateGroupRecipeAction, |     CreateGroupRecipeAction, | ||||||
|     GroupRecipeActionOut, |     GroupRecipeActionOut, | ||||||
|     GroupRecipeActionPagination, |     GroupRecipeActionPagination, | ||||||
|  |     GroupRecipeActionPayload, | ||||||
|  |     GroupRecipeActionType, | ||||||
|     SaveGroupRecipeAction, |     SaveGroupRecipeAction, | ||||||
| ) | ) | ||||||
|  | from mealie.schema.response import ErrorResponse | ||||||
| from mealie.schema.response.pagination import PaginationQuery | from mealie.schema.response.pagination import PaginationQuery | ||||||
|  | from mealie.services.recipe.recipe_service import RecipeService | ||||||
|  |  | ||||||
| router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"]) | router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"]) | ||||||
|  |  | ||||||
| @@ -27,6 +34,9 @@ class GroupRecipeActionController(BaseUserController): | |||||||
|     def mixins(self): |     def mixins(self): | ||||||
|         return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger) |         return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger) | ||||||
|  |  | ||||||
|  |     # ================================================================================================================== | ||||||
|  |     # CRUD | ||||||
|  |  | ||||||
|     @router.get("", response_model=GroupRecipeActionPagination) |     @router.get("", response_model=GroupRecipeActionPagination) | ||||||
|     def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): |     def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): | ||||||
|         response = self.repo.page_all( |         response = self.repo.page_all( | ||||||
| @@ -53,3 +63,40 @@ class GroupRecipeActionController(BaseUserController): | |||||||
|     @router.delete("/{item_id}", response_model=GroupRecipeActionOut) |     @router.delete("/{item_id}", response_model=GroupRecipeActionOut) | ||||||
|     def delete_one(self, item_id: UUID4): |     def delete_one(self, item_id: UUID4): | ||||||
|         return self.mixins.delete_one(item_id) |         return self.mixins.delete_one(item_id) | ||||||
|  |  | ||||||
|  |     # ================================================================================================================== | ||||||
|  |     # Actions | ||||||
|  |  | ||||||
|  |     @router.post("/{item_id}/trigger/{recipe_slug}", status_code=202) | ||||||
|  |     def trigger_action(self, item_id: UUID4, recipe_slug: str, bg_tasks: BackgroundTasks) -> None: | ||||||
|  |         recipe_action = self.repos.group_recipe_actions.get_one(item_id) | ||||||
|  |         if not recipe_action: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status.HTTP_404_NOT_FOUND, | ||||||
|  |                 detail=ErrorResponse.respond(message="Not found."), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if recipe_action.action_type == GroupRecipeActionType.post.value: | ||||||
|  |             task_action = requests.post | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail=ErrorResponse.respond(message=f'Cannot trigger action type "{recipe_action.action_type}".'), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         recipe_service = RecipeService(self.repos, self.user, self.household, translator=self.translator) | ||||||
|  |         try: | ||||||
|  |             recipe = recipe_service.get_one(recipe_slug) | ||||||
|  |         except NoEntryFound as e: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status.HTTP_404_NOT_FOUND, | ||||||
|  |                 detail=ErrorResponse.respond(message="Not found."), | ||||||
|  |             ) from e | ||||||
|  |  | ||||||
|  |         payload = GroupRecipeActionPayload(action=recipe_action, content=recipe) | ||||||
|  |         bg_tasks.add_task( | ||||||
|  |             task_action, | ||||||
|  |             url=recipe_action.url, | ||||||
|  |             json=jsonable_encoder(payload.model_dump()), | ||||||
|  |             timeout=15, | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ from .group_recipe_action import ( | |||||||
|     CreateGroupRecipeAction, |     CreateGroupRecipeAction, | ||||||
|     GroupRecipeActionOut, |     GroupRecipeActionOut, | ||||||
|     GroupRecipeActionPagination, |     GroupRecipeActionPagination, | ||||||
|  |     GroupRecipeActionPayload, | ||||||
|     GroupRecipeActionType, |     GroupRecipeActionType, | ||||||
|     SaveGroupRecipeAction, |     SaveGroupRecipeAction, | ||||||
| ) | ) | ||||||
| @@ -75,6 +76,7 @@ __all__ = [ | |||||||
|     "CreateGroupRecipeAction", |     "CreateGroupRecipeAction", | ||||||
|     "GroupRecipeActionOut", |     "GroupRecipeActionOut", | ||||||
|     "GroupRecipeActionPagination", |     "GroupRecipeActionPagination", | ||||||
|  |     "GroupRecipeActionPayload", | ||||||
|     "GroupRecipeActionType", |     "GroupRecipeActionType", | ||||||
|     "SaveGroupRecipeAction", |     "SaveGroupRecipeAction", | ||||||
|     "CreateWebhook", |     "CreateWebhook", | ||||||
|   | |||||||
| @@ -1,10 +1,14 @@ | |||||||
| from enum import Enum | from enum import Enum | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
| from pydantic import UUID4, ConfigDict | from pydantic import UUID4, ConfigDict | ||||||
|  |  | ||||||
| from mealie.schema._mealie import MealieModel | from mealie.schema._mealie import MealieModel | ||||||
| from mealie.schema.response.pagination import PaginationBase | from mealie.schema.response.pagination import PaginationBase | ||||||
|  |  | ||||||
|  | # ================================================================================================================== | ||||||
|  | # CRUD | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupRecipeActionType(Enum): | class GroupRecipeActionType(Enum): | ||||||
|     link = "link" |     link = "link" | ||||||
| @@ -31,3 +35,12 @@ class GroupRecipeActionOut(SaveGroupRecipeAction): | |||||||
|  |  | ||||||
| class GroupRecipeActionPagination(PaginationBase): | class GroupRecipeActionPagination(PaginationBase): | ||||||
|     items: list[GroupRecipeActionOut] |     items: list[GroupRecipeActionOut] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # ================================================================================================================== | ||||||
|  | # Actions | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GroupRecipeActionPayload(MealieModel): | ||||||
|  |     action: GroupRecipeActionOut | ||||||
|  |     content: Any | ||||||
|   | |||||||
| @@ -1,26 +1,51 @@ | |||||||
|  | from uuid import UUID, uuid4 | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  | import requests | ||||||
| from fastapi.testclient import TestClient | from fastapi.testclient import TestClient | ||||||
|  |  | ||||||
| from mealie.schema.household.group_recipe_action import ( | from mealie.schema.household.group_recipe_action import ( | ||||||
|     CreateGroupRecipeAction, |     CreateGroupRecipeAction, | ||||||
|     GroupRecipeActionOut, |     GroupRecipeActionOut, | ||||||
|     GroupRecipeActionType, |     GroupRecipeActionType, | ||||||
|  |     SaveGroupRecipeAction, | ||||||
| ) | ) | ||||||
|  | from mealie.schema.recipe.recipe import Recipe | ||||||
| from tests.utils import api_routes, assert_deserialize | from tests.utils import api_routes, assert_deserialize | ||||||
| from tests.utils.factories import random_int, random_string | from tests.utils.factories import random_int, random_string | ||||||
| from tests.utils.fixture_schemas import TestUser | from tests.utils.fixture_schemas import TestUser | ||||||
|  |  | ||||||
|  |  | ||||||
| def new_link_action() -> CreateGroupRecipeAction: | @pytest.fixture(autouse=True) | ||||||
|  | def mock_requests_post(monkeypatch: pytest.MonkeyPatch): | ||||||
|  |     monkeypatch.setattr(requests, "post", lambda *args, **kwargs: None) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_action(action_type: GroupRecipeActionType = GroupRecipeActionType.link) -> CreateGroupRecipeAction: | ||||||
|     return CreateGroupRecipeAction( |     return CreateGroupRecipeAction( | ||||||
|         action_type=GroupRecipeActionType.link, |         action_type=action_type, | ||||||
|         title=random_string(), |         title=random_string(), | ||||||
|         url=random_string(), |         url=random_string(), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def save_action( | ||||||
|  |     user: TestUser, action_type: GroupRecipeActionType = GroupRecipeActionType.link | ||||||
|  | ) -> SaveGroupRecipeAction: | ||||||
|  |     action = create_action(action_type) | ||||||
|  |     return action.cast(SaveGroupRecipeAction, group_id=UUID(user.group_id), household_id=UUID(user.household_id)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def new_recipe(user: TestUser) -> Recipe: | ||||||
|  |     return Recipe( | ||||||
|  |         user_id=user.user_id, | ||||||
|  |         group_id=UUID(user.group_id), | ||||||
|  |         name=random_string(), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser): | def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser): | ||||||
|     action_in = new_link_action() |     action_in = create_action() | ||||||
|     response = api_client.post( |     response = api_client.post( | ||||||
|         api_routes.households_recipe_actions, |         api_routes.households_recipe_actions, | ||||||
|         json=action_in.model_dump(), |         json=action_in.model_dump(), | ||||||
| @@ -42,7 +67,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU | |||||||
|     for _ in range(random_int(3, 5)): |     for _ in range(random_int(3, 5)): | ||||||
|         response = api_client.post( |         response = api_client.post( | ||||||
|             api_routes.households_recipe_actions, |             api_routes.households_recipe_actions, | ||||||
|             json=new_link_action().model_dump(), |             json=create_action().model_dump(), | ||||||
|             headers=unique_user.token, |             headers=unique_user.token, | ||||||
|         ) |         ) | ||||||
|         data = assert_deserialize(response, 201) |         data = assert_deserialize(response, 201) | ||||||
| @@ -59,7 +84,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU | |||||||
| def test_group_recipe_actions_get_one( | def test_group_recipe_actions_get_one( | ||||||
|     api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool |     api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool | ||||||
| ): | ): | ||||||
|     action_in = new_link_action() |     action_in = create_action() | ||||||
|     response = api_client.post( |     response = api_client.post( | ||||||
|         api_routes.households_recipe_actions, |         api_routes.households_recipe_actions, | ||||||
|         json=action_in.model_dump(), |         json=action_in.model_dump(), | ||||||
| @@ -87,7 +112,7 @@ def test_group_recipe_actions_get_one( | |||||||
|  |  | ||||||
|  |  | ||||||
| def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser): | def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser): | ||||||
|     action_in = new_link_action() |     action_in = create_action() | ||||||
|     response = api_client.post( |     response = api_client.post( | ||||||
|         api_routes.households_recipe_actions, |         api_routes.households_recipe_actions, | ||||||
|         json=action_in.model_dump(), |         json=action_in.model_dump(), | ||||||
| @@ -110,7 +135,7 @@ def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: Te | |||||||
|  |  | ||||||
|  |  | ||||||
| def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser): | def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser): | ||||||
|     action_in = new_link_action() |     action_in = create_action() | ||||||
|     response = api_client.post( |     response = api_client.post( | ||||||
|         api_routes.households_recipe_actions, |         api_routes.households_recipe_actions, | ||||||
|         json=action_in.model_dump(), |         json=action_in.model_dump(), | ||||||
| @@ -124,3 +149,46 @@ def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: Te | |||||||
|  |  | ||||||
|     response = api_client.get(api_routes.households_recipe_actions_item_id(action_id), headers=unique_user.token) |     response = api_client.get(api_routes.households_recipe_actions_item_id(action_id), headers=unique_user.token) | ||||||
|     assert response.status_code == 404 |     assert response.status_code == 404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("missing_action", [True, False]) | ||||||
|  | @pytest.mark.parametrize("missing_recipe", [True, False]) | ||||||
|  | def test_group_recipe_actions_trigger_post( | ||||||
|  |     api_client: TestClient, unique_user: TestUser, missing_action: bool, missing_recipe: bool | ||||||
|  | ): | ||||||
|  |     if missing_action: | ||||||
|  |         action_id = uuid4() | ||||||
|  |     else: | ||||||
|  |         recipe_action = unique_user.repos.group_recipe_actions.create( | ||||||
|  |             save_action(unique_user, GroupRecipeActionType.post) | ||||||
|  |         ) | ||||||
|  |         action_id = recipe_action.id | ||||||
|  |  | ||||||
|  |     if missing_recipe: | ||||||
|  |         recipe_slug = random_string() | ||||||
|  |     else: | ||||||
|  |         recipe = unique_user.repos.recipes.create(new_recipe(unique_user)) | ||||||
|  |         recipe_slug = recipe.slug | ||||||
|  |  | ||||||
|  |     response = api_client.post( | ||||||
|  |         api_routes.households_recipe_actions_item_id_trigger_recipe_slug(action_id, recipe_slug), | ||||||
|  |         headers=unique_user.token, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     if missing_action or missing_recipe: | ||||||
|  |         assert response.status_code == 404 | ||||||
|  |     else: | ||||||
|  |         # we don't test if the request was actually made, just that the endpoint was hit and accepted | ||||||
|  |         assert response.status_code == 202 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_group_recipe_actions_trigger_invalid_type(api_client: TestClient, unique_user: TestUser): | ||||||
|  |     recipe_action = unique_user.repos.group_recipe_actions.create(save_action(unique_user, GroupRecipeActionType.link)) | ||||||
|  |     recipe = unique_user.repos.recipes.create(new_recipe(unique_user)) | ||||||
|  |  | ||||||
|  |     response = api_client.post( | ||||||
|  |         api_routes.households_recipe_actions_item_id_trigger_recipe_slug(recipe_action.id, recipe.id), | ||||||
|  |         headers=unique_user.token, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert response.status_code == 400 | ||||||
|   | |||||||
| @@ -332,6 +332,11 @@ def households_recipe_actions_item_id(item_id): | |||||||
|     return f"{prefix}/households/recipe-actions/{item_id}" |     return f"{prefix}/households/recipe-actions/{item_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def households_recipe_actions_item_id_trigger_recipe_slug(item_id, recipe_slug): | ||||||
|  |     """`/api/households/recipe-actions/{item_id}/trigger/{recipe_slug}`""" | ||||||
|  |     return f"{prefix}/households/recipe-actions/{item_id}/trigger/{recipe_slug}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def households_shopping_items_item_id(item_id): | def households_shopping_items_item_id(item_id): | ||||||
|     """`/api/households/shopping/items/{item_id}`""" |     """`/api/households/shopping/items/{item_id}`""" | ||||||
|     return f"{prefix}/households/shopping/items/{item_id}" |     return f"{prefix}/households/shopping/items/{item_id}" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user