From 43806553fc3314a3e6e7cee864dff45cba262a85 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker <joostlek@outlook.com> Date: Wed, 10 Jul 2024 14:33:17 +0200 Subject: [PATCH] Add service to import recipe to mealie (#121598) --- homeassistant/components/mealie/const.py | 2 + homeassistant/components/mealie/icons.json | 3 +- homeassistant/components/mealie/services.py | 46 ++++- homeassistant/components/mealie/services.yaml | 14 ++ homeassistant/components/mealie/strings.json | 21 ++ tests/components/mealie/conftest.py | 6 +- .../mealie/snapshots/test_services.ambr | 190 ++++++++++++++++++ tests/components/mealie/test_services.py | 83 +++++++- 8 files changed, 359 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index 4de0863ada7..0eb7d98164c 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -10,3 +10,5 @@ ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" ATTR_RECIPE_ID = "recipe_id" +ATTR_URL = "url" +ATTR_INCLUDE_TAGS = "include_tags" diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 2be9b1f9b20..87aefc3d91f 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -8,6 +8,7 @@ }, "services": { "get_mealplan": "mdi:food", - "get_recipe": "mdi:map" + "get_recipe": "mdi:map", + "import_recipe": "mdi:map-search" } } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 61323018b17..ac8d5519310 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -4,7 +4,11 @@ from dataclasses import asdict from datetime import date from typing import cast -from aiomealie.exceptions import MealieConnectionError, MealieNotFoundError +from aiomealie.exceptions import ( + MealieConnectionError, + MealieNotFoundError, + MealieValidationError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -19,8 +23,10 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, + ATTR_INCLUDE_TAGS, ATTR_RECIPE_ID, ATTR_START_DATE, + ATTR_URL, DOMAIN, ) from .coordinator import MealieConfigEntry @@ -42,6 +48,15 @@ SERVICE_GET_RECIPE_SCHEMA = vol.Schema( } ) +SERVICE_IMPORT_RECIPE = "import_recipe" +SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_URL): str, + vol.Optional(ATTR_INCLUDE_TAGS): bool, + } +) + def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEntry: """Get the Mealie config entry.""" @@ -103,6 +118,28 @@ def setup_services(hass: HomeAssistant) -> None: ) from err return {"recipe": asdict(recipe)} + async def async_import_recipe(call: ServiceCall) -> ServiceResponse: + """Import a recipe.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + url = call.data[ATTR_URL] + include_tags = call.data.get(ATTR_INCLUDE_TAGS, False) + client = entry.runtime_data.client + try: + recipe = await client.import_recipe(url, include_tags) + except MealieValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="could_not_import_recipe", + ) from err + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: + return {"recipe": asdict(recipe)} + return None + hass.services.async_register( DOMAIN, SERVICE_GET_MEALPLAN, @@ -117,3 +154,10 @@ def setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_RECIPE_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_IMPORT_RECIPE, + async_import_recipe, + schema=SERVICE_IMPORT_RECIPE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index a74935c1b31..21043112579 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -22,3 +22,17 @@ get_recipe: required: true selector: text: +import_recipe: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mealie + url: + required: true + selector: + text: + include_tags: + selector: + boolean: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 63097ae3368..0e54a64b199 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -52,6 +52,9 @@ "recipe_not_found": { "message": "Recipe with ID or slug `{recipe_id}` not found." }, + "could_not_import_recipe": { + "message": "Mealie could not import the recipe from the URL." + }, "add_item_error": { "message": "An error occurred adding an item to {shopping_list_name}." }, @@ -97,6 +100,24 @@ "description": "The recipe ID or the slug of the recipe to get." } } + }, + "import_recipe": { + "name": "Import recipe", + "description": "Import recipe from an URL", + "fields": { + "config_entry_id": { + "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", + "description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]" + }, + "url": { + "name": "URL to the recipe", + "description": "The URL to the recipe to import." + }, + "include_tags": { + "name": "Include tags", + "description": "Include tags from the website to the recipe." + } + } } } } diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index c91efe7f767..be9f939267a 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -61,15 +61,15 @@ def mock_mealie_client() -> Generator[AsyncMock]: client.get_about.return_value = About.from_json( load_fixture("about.json", DOMAIN) ) + recipe = Recipe.from_json(load_fixture("get_recipe.json", DOMAIN)) + client.get_recipe.return_value = recipe + client.import_recipe.return_value = recipe client.get_shopping_lists.return_value = ShoppingListsResponse.from_json( load_fixture("get_shopping_lists.json", DOMAIN) ) client.get_shopping_items.return_value = ShoppingItemsResponse.from_json( load_fixture("get_shopping_items.json", DOMAIN) ) - client.get_recipe.return_value = Recipe.from_json( - load_fixture("get_recipe.json", DOMAIN) - ) yield client diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 0dc7e7ab1c3..7bda79e14a6 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1,4 +1,194 @@ # serializer version: 1 +# name: test_service_import_recipe + dict({ + 'recipe': dict({ + 'date_added': datetime.date(2024, 6, 29), + 'description': 'The world’s most famous cake, the Original Sacher-Torte, is the consequence of several lucky twists of fate. The first was in 1832, when the Austrian State Chancellor, Prince Klemens Wenzel von Metternich, tasked his kitchen staff with concocting an extraordinary dessert to impress his special guests. As fortune had it, the chef had fallen ill that evening, leaving the apprentice chef, the then-16-year-old Franz Sacher, to perform this culinary magic trick. Metternich’s parting words to the talented teenager: “I hope you won’t disgrace me tonight.”', + 'group_id': '24477569-f6af-4b53-9e3f-6d04b0ca6916', + 'image': 'SuPW', + 'ingredients': list([ + dict({ + 'is_food': True, + 'note': '130g dark couverture chocolate (min. 55% cocoa content)', + 'quantity': 1.0, + 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '1 Vanilla Pod', + 'quantity': 1.0, + 'reference_id': '41d234d7-c040-48f9-91e6-f4636aebb77b', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '150g softened butter', + 'quantity': 1.0, + 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '100g Icing sugar', + 'quantity': 1.0, + 'reference_id': 'f7fcd86e-b04b-4e07-b69c-513925811491', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '6 Eggs', + 'quantity': 1.0, + 'reference_id': 'a831fbc3-e2f5-452e-a745-450be8b4a130', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '100g Castor sugar', + 'quantity': 1.0, + 'reference_id': 'b5ee4bdc-0047-4de7-968b-f3360bbcb31e', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '140g Plain wheat flour', + 'quantity': 1.0, + 'reference_id': 'a67db09d-429c-4e77-919d-cfed3da675ad', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '200g apricot jam', + 'quantity': 1.0, + 'reference_id': '55479752-c062-4b25-aae3-2b210999d7b9', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '200g castor sugar', + 'quantity': 1.0, + 'reference_id': 'ff9cd404-24ec-4d38-b0aa-0120ce1df679', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': '150g dark couverture chocolate (min. 55% cocoa content)', + 'quantity': 1.0, + 'reference_id': 'c7fca92e-971e-4728-a227-8b04783583ed', + 'unit': None, + }), + dict({ + 'is_food': True, + 'note': 'Unsweetend whipped cream to garnish', + 'quantity': 1.0, + 'reference_id': 'ef023f23-7816-4871-87f6-4d29f9a283f7', + 'unit': None, + }), + ]), + 'instructions': list([ + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '2d558dbf-5361-4ef2-9d86-4161f5eb6146', + 'text': 'Preheat oven to 170°C. Line the base of a springform with baking paper, grease the sides, and dust with a little flour. Melt couverture over boiling water. Let cool slightly.', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': 'dbcc1c37-3cbf-4045-9902-8f7fd1e68f0a', + 'text': 'Slit vanilla pod lengthwise and scrape out seeds. Using a hand mixer with whisks, beat the softened butter with the icing sugar and vanilla seeds until bubbles appear.', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '2265bd14-a691-40b1-9fe6-7b5dfeac8401', + 'text': 'Separate the eggs. Whisk the egg yolks into the butter mixture one by one. Now gradually add melted couverture chocolate. Beat the egg whites with the castor sugar until stiff, then place on top of the butter and chocolate mixture. Sift the flour over the mixture, then fold in the flour and beaten egg whites.', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '0aade447-dfac-4aae-8e67-ac250ad13ae2', + 'text': "Transfer the mixture to the springform, smooth the top, and bake in the oven (middle rack) for 10–15 minutes, leaving the oven door a finger's width ajar. Then close the oven and bake for approximately 50 minutes. (The cake is done when it yields slightly to the touch.)", + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '5fdcb703-7103-468d-a65d-a92460b92eb3', + 'text': 'Remove the cake from the oven and loosen the sides of the springform. Carefully tip the cake onto a cake rack lined with baking paper and let cool for approximately 20 minutes. Then pull off the baking paper, turn the cake over, and leave on rack to cool completely.', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '81474afc-b44e-49b3-bb67-5d7dab8f832a', + 'text': 'Cut the cake in half horizontally. Warm the jam and stir until smooth. Brush the top of both cake halves with the jam and place one on top of the other. Brush the sides with the jam as well.', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '8fac8aee-0d3c-4f78-9ff8-56d20472e5f1', + 'text': 'To make the glaze, put the castor sugar into a saucepan with 125 ml water and boil over high heat for approximately 5 minutes. Take the sugar syrup off the stove and leave to cool a little. Coarsely chop the couverture, gradually adding it to the syrup, and stir until it forms a thick liquid (see tip below).', + 'title': None, + }), + dict({ + 'ingredient_references': list([ + ]), + 'instruction_id': '7162e099-d651-4656-902a-a09a9b40c4e1', + 'text': 'Pour all the lukewarm glaze liquid at once over the top of the cake and quickly spread using a palette knife. Leave the glaze to set for a few hours. Serve garnished with whipped cream.', + 'title': None, + }), + ]), + 'name': 'Original Sacher-Torte (2)', + 'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/', + 'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93', + 'recipe_yield': '4 servings', + 'slug': 'original-sacher-torte-2', + 'tags': list([ + dict({ + 'name': 'Sacher', + 'slug': 'sacher', + 'tag_id': '1b5789b9-3af6-412e-8c77-8a01caa0aac9', + }), + dict({ + 'name': 'Cake', + 'slug': 'cake', + 'tag_id': '1cf17f96-58b5-4bd3-b1e8-1606a64b413d', + }), + dict({ + 'name': 'Torte', + 'slug': 'torte', + 'tag_id': '3f5f0a3d-728f-440d-a6c7-5a68612e8c67', + }), + dict({ + 'name': 'Sachertorte', + 'slug': 'sachertorte', + 'tag_id': '525f388d-6ee0-4ebe-91fc-dd320a7583f0', + }), + dict({ + 'name': 'Sacher Torte Cake', + 'slug': 'sacher-torte-cake', + 'tag_id': '544a6e08-a899-4f63-9c72-bb2924df70cb', + }), + dict({ + 'name': 'Sacher Torte', + 'slug': 'sacher-torte', + 'tag_id': '576c0a82-84ee-4e50-a14e-aa7a675b6352', + }), + dict({ + 'name': 'Original Sachertorte', + 'slug': 'original-sachertorte', + 'tag_id': 'd530b8e4-275a-4093-804b-6d0de154c206', + }), + ]), + 'user_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0', + }), + }) +# --- # name: test_service_mealplan dict({ 'mealplan': list([ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 401e915f4b0..b6928f88f2c 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -3,7 +3,11 @@ from datetime import date from unittest.mock import AsyncMock -from aiomealie.exceptions import MealieConnectionError, MealieNotFoundError +from aiomealie.exceptions import ( + MealieConnectionError, + MealieNotFoundError, + MealieValidationError, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -11,13 +15,16 @@ from syrupy import SnapshotAssertion from homeassistant.components.mealie.const import ( ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, + ATTR_INCLUDE_TAGS, ATTR_RECIPE_ID, ATTR_START_DATE, + ATTR_URL, DOMAIN, ) from homeassistant.components.mealie.services import ( SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, + SERVICE_IMPORT_RECIPE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -136,6 +143,47 @@ async def test_service_recipe( assert response == snapshot +async def test_service_import_recipe( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the import_recipe service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_IMPORT_RECIPE, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_URL: "http://example.com", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + mock_mealie_client.import_recipe.assert_called_with( + "http://example.com", include_tags=False + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_IMPORT_RECIPE, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_URL: "http://example.com", + ATTR_INCLUDE_TAGS: True, + }, + blocking=True, + return_response=False, + ) + mock_mealie_client.import_recipe.assert_called_with( + "http://example.com", include_tags=True + ) + + @pytest.mark.parametrize( ("exception", "raised_exception"), [ @@ -169,6 +217,39 @@ async def test_service_recipe_exceptions( ) +@pytest.mark.parametrize( + ("exception", "raised_exception"), + [ + (MealieValidationError, ServiceValidationError), + (MealieConnectionError, HomeAssistantError), + ], +) +async def test_service_import_recipe_exceptions( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + raised_exception: type[Exception], +) -> None: + """Test the exceptions of the import_recipe service.""" + + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.import_recipe.side_effect = exception + + with pytest.raises(raised_exception): + await hass.services.async_call( + DOMAIN, + SERVICE_IMPORT_RECIPE, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_URL: "http://example.com", + }, + blocking=True, + return_response=True, + ) + + async def test_service_mealplan_connection_error( hass: HomeAssistant, mock_mealie_client: AsyncMock, -- GitLab