From 8b8d8459dc49a6376ad672720373291db0afeb39 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 17:47:35 +0000 Subject: [PATCH 1/6] added backend route to trigger a recipe action --- .../controller_group_recipe_actions.py | 47 ++++++++++++++++++- .../schema/household/group_recipe_action.py | 13 +++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/mealie/routes/households/controller_group_recipe_actions.py b/mealie/routes/households/controller_group_recipe_actions.py index b3d78e05c90..5b153f24ceb 100644 --- a/mealie/routes/households/controller_group_recipe_actions.py +++ b/mealie/routes/households/controller_group_recipe_actions.py @@ -1,6 +1,8 @@ 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 mealie.routes._base.base_controllers import BaseUserController @@ -10,9 +12,13 @@ CreateGroupRecipeAction, GroupRecipeActionOut, GroupRecipeActionPagination, + GroupRecipeActionPayload, + GroupRecipeActionType, SaveGroupRecipeAction, ) +from mealie.schema.response import ErrorResponse 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"]) @@ -27,6 +33,9 @@ def repo(self): def mixins(self): return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger) + # ================================================================================================================== + # CRUD + @router.get("", response_model=GroupRecipeActionPagination) def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): response = self.repo.page_all( @@ -53,3 +62,39 @@ def update_one(self, item_id: UUID4, data: SaveGroupRecipeAction): @router.delete("/{item_id}", response_model=GroupRecipeActionOut) def delete_one(self, item_id: UUID4): 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) + recipe = recipe_service.get_one(recipe_slug) + if not recipe: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=ErrorResponse.respond(message="Not found."), + ) + + 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, + ) diff --git a/mealie/schema/household/group_recipe_action.py b/mealie/schema/household/group_recipe_action.py index 229571a4c56..4794c8e65c0 100644 --- a/mealie/schema/household/group_recipe_action.py +++ b/mealie/schema/household/group_recipe_action.py @@ -1,10 +1,14 @@ from enum import Enum +from typing import Any from pydantic import UUID4, ConfigDict from mealie.schema._mealie import MealieModel from mealie.schema.response.pagination import PaginationBase +# ================================================================================================================== +# CRUD + class GroupRecipeActionType(Enum): link = "link" @@ -31,3 +35,12 @@ class GroupRecipeActionOut(SaveGroupRecipeAction): class GroupRecipeActionPagination(PaginationBase): items: list[GroupRecipeActionOut] + + +# ================================================================================================================== +# Actions + + +class GroupRecipeActionPayload(MealieModel): + action: GroupRecipeActionOut + content: Any From fca437f207ce0f4939a0aa793df67ce6f471ae9f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:11:07 +0000 Subject: [PATCH 2/6] fix recipe 404 --- .../routes/households/controller_group_recipe_actions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mealie/routes/households/controller_group_recipe_actions.py b/mealie/routes/households/controller_group_recipe_actions.py index 5b153f24ceb..a99c577657f 100644 --- a/mealie/routes/households/controller_group_recipe_actions.py +++ b/mealie/routes/households/controller_group_recipe_actions.py @@ -5,6 +5,7 @@ from fastapi.encoders import jsonable_encoder from pydantic import UUID4 +from mealie.core.exceptions import NoEntryFound from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo @@ -84,12 +85,13 @@ def trigger_action(self, item_id: UUID4, recipe_slug: str, bg_tasks: BackgroundT ) recipe_service = RecipeService(self.repos, self.user, self.household, translator=self.translator) - recipe = recipe_service.get_one(recipe_slug) - if not recipe: + 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( From 530cf038c994cf55ce6d3e4839bf3bc212f5834e Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:11:15 +0000 Subject: [PATCH 3/6] dev:generate --- frontend/lib/api/types/household.ts | 4 ++++ mealie/schema/household/__init__.py | 2 ++ tests/utils/api_routes/__init__.py | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/frontend/lib/api/types/household.ts b/frontend/lib/api/types/household.ts index e97d8485be5..7841bd3861f 100644 --- a/frontend/lib/api/types/household.ts +++ b/frontend/lib/api/types/household.ts @@ -165,6 +165,10 @@ export interface GroupRecipeActionOut { householdId: string; id: string; } +export interface GroupRecipeActionPayload { + action: GroupRecipeActionOut; + content: unknown; +} export interface HouseholdCreate { groupId?: string | null; name: string; diff --git a/mealie/schema/household/__init__.py b/mealie/schema/household/__init__.py index 52f13182d8d..42df6063262 100644 --- a/mealie/schema/household/__init__.py +++ b/mealie/schema/household/__init__.py @@ -14,6 +14,7 @@ CreateGroupRecipeAction, GroupRecipeActionOut, GroupRecipeActionPagination, + GroupRecipeActionPayload, GroupRecipeActionType, SaveGroupRecipeAction, ) @@ -75,6 +76,7 @@ "CreateGroupRecipeAction", "GroupRecipeActionOut", "GroupRecipeActionPagination", + "GroupRecipeActionPayload", "GroupRecipeActionType", "SaveGroupRecipeAction", "CreateWebhook", diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index 6f2bc33c0c9..86c56f8e80f 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -332,6 +332,11 @@ def households_recipe_actions_item_id(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): """`/api/households/shopping/items/{item_id}`""" return f"{prefix}/households/shopping/items/{item_id}" From 1746f1efdf615555c04af37126a42028dbee3c9f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:11:22 +0000 Subject: [PATCH 4/6] added tests --- .../test_group_recipe_actions.py | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/tests/integration_tests/user_household_tests/test_group_recipe_actions.py b/tests/integration_tests/user_household_tests/test_group_recipe_actions.py index 157477c2d3d..b55a9bfc6df 100644 --- a/tests/integration_tests/user_household_tests/test_group_recipe_actions.py +++ b/tests/integration_tests/user_household_tests/test_group_recipe_actions.py @@ -1,26 +1,51 @@ +from uuid import UUID, uuid4 + import pytest +import requests from fastapi.testclient import TestClient from mealie.schema.household.group_recipe_action import ( CreateGroupRecipeAction, GroupRecipeActionOut, GroupRecipeActionType, + SaveGroupRecipeAction, ) +from mealie.schema.recipe.recipe import Recipe from tests.utils import api_routes, assert_deserialize from tests.utils.factories import random_int, random_string 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( - action_type=GroupRecipeActionType.link, + action_type=action_type, title=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): - action_in = new_link_action() + action_in = create_action() response = api_client.post( api_routes.households_recipe_actions, 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)): response = api_client.post( api_routes.households_recipe_actions, - json=new_link_action().model_dump(), + json=create_action().model_dump(), headers=unique_user.token, ) 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( 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( api_routes.households_recipe_actions, 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): - action_in = new_link_action() + action_in = create_action() response = api_client.post( api_routes.households_recipe_actions, 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): - action_in = new_link_action() + action_in = create_action() response = api_client.post( api_routes.households_recipe_actions, 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) 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 From c926efc7bbe7f4cebb8e85a9719e5e5afa9be9e9 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 19:45:36 +0000 Subject: [PATCH 5/6] updated frontend to use new endpoint --- .../Domain/Recipe/RecipeContextMenu.vue | 2 +- .../composables/use-group-recipe-actions.ts | 18 ++++-------------- frontend/lib/api/user/group-recipe-actions.ts | 5 +++++ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index ebebeec647e..0f847d60e5d 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -376,7 +376,7 @@ export default defineComponent({ const response = await groupRecipeActionsStore.execute(action, props.recipe); if (action.actionType === "post") { - if (!response || (response.status >= 200 && response.status < 300)) { + if (!response?.error) { alert.success(i18n.tc("events.message-sent")); } else { alert.error(i18n.tc("events.something-went-wrong")); diff --git a/frontend/composables/use-group-recipe-actions.ts b/frontend/composables/use-group-recipe-actions.ts index d3166bc40d1..2701c0fe4c0 100644 --- a/frontend/composables/use-group-recipe-actions.ts +++ b/frontend/composables/use-group-recipe-actions.ts @@ -2,6 +2,7 @@ import { computed, reactive, ref } from "@nuxtjs/composition-api"; import { useStoreActions } from "./partials/use-actions-factory"; import { useUserApi } from "~/composables/api"; import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household"; +import { RequestResponse } from "~/lib/api/types/non-generated"; import { Recipe } from "~/lib/api/types/recipe"; const groupRecipeActions = ref(null); @@ -54,26 +55,15 @@ export const useGroupRecipeActions = function ( /* eslint-enable no-template-curly-in-string */ }; - async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise { + async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise> { const url = parseRecipeActionUrl(action.url, recipe); switch (action.actionType) { case "link": window.open(url, "_blank")?.focus(); - break; + return; case "post": - return await fetch(url, { - 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); - }); + return await api.groupRecipeActions.triggerAction(action.id, recipe.slug || ""); default: break; } diff --git a/frontend/lib/api/user/group-recipe-actions.ts b/frontend/lib/api/user/group-recipe-actions.ts index 078a9311e2e..d49cdecec7e 100644 --- a/frontend/lib/api/user/group-recipe-actions.ts +++ b/frontend/lib/api/user/group-recipe-actions.ts @@ -6,9 +6,14 @@ const prefix = "/api"; const routes = { groupRecipeActions: `${prefix}/households/recipe-actions`, 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 { baseRoute = routes.groupRecipeActions; itemRoute = routes.groupRecipeActionsId; + + async triggerAction(id: string | number, recipeSlug: string) { + return await this.requests.post(routes.groupRecipeActionsIdTriggerRecipeSlug(id, recipeSlug), {}); + } } From efd1284746c8dd0842cdcb490ec28e847824a69f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 5 Sep 2024 19:52:34 +0000 Subject: [PATCH 6/6] updated docs to clarify that only link actions use merge fields --- docs/docs/documentation/getting-started/features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/documentation/getting-started/features.md b/docs/docs/documentation/getting-started/features.md index b9ee0cebd67..797c4d0d083 100644 --- a/docs/docs/documentation/getting-started/features.md +++ b/docs/docs/documentation/getting-started/features.md @@ -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: -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 -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} ```