Add service to import recipe to mealie (#121598)

This commit is contained in:
Joost Lekkerkerker 2024-07-10 14:33:17 +02:00 committed by GitHub
parent f762359abf
commit 43806553fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 359 additions and 6 deletions

View file

@ -10,3 +10,5 @@ ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_START_DATE = "start_date" ATTR_START_DATE = "start_date"
ATTR_END_DATE = "end_date" ATTR_END_DATE = "end_date"
ATTR_RECIPE_ID = "recipe_id" ATTR_RECIPE_ID = "recipe_id"
ATTR_URL = "url"
ATTR_INCLUDE_TAGS = "include_tags"

View file

@ -8,6 +8,7 @@
}, },
"services": { "services": {
"get_mealplan": "mdi:food", "get_mealplan": "mdi:food",
"get_recipe": "mdi:map" "get_recipe": "mdi:map",
"import_recipe": "mdi:map-search"
} }
} }

View file

@ -4,7 +4,11 @@ from dataclasses import asdict
from datetime import date from datetime import date
from typing import cast from typing import cast
from aiomealie.exceptions import MealieConnectionError, MealieNotFoundError from aiomealie.exceptions import (
MealieConnectionError,
MealieNotFoundError,
MealieValidationError,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -19,8 +23,10 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import ( from .const import (
ATTR_CONFIG_ENTRY_ID, ATTR_CONFIG_ENTRY_ID,
ATTR_END_DATE, ATTR_END_DATE,
ATTR_INCLUDE_TAGS,
ATTR_RECIPE_ID, ATTR_RECIPE_ID,
ATTR_START_DATE, ATTR_START_DATE,
ATTR_URL,
DOMAIN, DOMAIN,
) )
from .coordinator import MealieConfigEntry 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: def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEntry:
"""Get the Mealie config entry.""" """Get the Mealie config entry."""
@ -103,6 +118,28 @@ def setup_services(hass: HomeAssistant) -> None:
) from err ) from err
return {"recipe": asdict(recipe)} 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( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_GET_MEALPLAN, SERVICE_GET_MEALPLAN,
@ -117,3 +154,10 @@ def setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_GET_RECIPE_SCHEMA, schema=SERVICE_GET_RECIPE_SCHEMA,
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
hass.services.async_register(
DOMAIN,
SERVICE_IMPORT_RECIPE,
async_import_recipe,
schema=SERVICE_IMPORT_RECIPE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)

View file

@ -22,3 +22,17 @@ get_recipe:
required: true required: true
selector: selector:
text: text:
import_recipe:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: mealie
url:
required: true
selector:
text:
include_tags:
selector:
boolean:

View file

@ -52,6 +52,9 @@
"recipe_not_found": { "recipe_not_found": {
"message": "Recipe with ID or slug `{recipe_id}` 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": { "add_item_error": {
"message": "An error occurred adding an item to {shopping_list_name}." "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." "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."
}
}
} }
} }
} }

View file

@ -61,15 +61,15 @@ def mock_mealie_client() -> Generator[AsyncMock]:
client.get_about.return_value = About.from_json( client.get_about.return_value = About.from_json(
load_fixture("about.json", DOMAIN) 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( client.get_shopping_lists.return_value = ShoppingListsResponse.from_json(
load_fixture("get_shopping_lists.json", DOMAIN) load_fixture("get_shopping_lists.json", DOMAIN)
) )
client.get_shopping_items.return_value = ShoppingItemsResponse.from_json( client.get_shopping_items.return_value = ShoppingItemsResponse.from_json(
load_fixture("get_shopping_items.json", DOMAIN) load_fixture("get_shopping_items.json", DOMAIN)
) )
client.get_recipe.return_value = Recipe.from_json(
load_fixture("get_recipe.json", DOMAIN)
)
yield client yield client

View file

@ -1,4 +1,194 @@
# serializer version: 1 # serializer version: 1
# name: test_service_import_recipe
dict({
'recipe': dict({
'date_added': datetime.date(2024, 6, 29),
'description': 'The worlds 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. Metternichs parting words to the talented teenager: “I hope you wont 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 1015 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 # name: test_service_mealplan
dict({ dict({
'mealplan': list([ 'mealplan': list([

View file

@ -3,7 +3,11 @@
from datetime import date from datetime import date
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aiomealie.exceptions import MealieConnectionError, MealieNotFoundError from aiomealie.exceptions import (
MealieConnectionError,
MealieNotFoundError,
MealieValidationError,
)
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -11,13 +15,16 @@ from syrupy import SnapshotAssertion
from homeassistant.components.mealie.const import ( from homeassistant.components.mealie.const import (
ATTR_CONFIG_ENTRY_ID, ATTR_CONFIG_ENTRY_ID,
ATTR_END_DATE, ATTR_END_DATE,
ATTR_INCLUDE_TAGS,
ATTR_RECIPE_ID, ATTR_RECIPE_ID,
ATTR_START_DATE, ATTR_START_DATE,
ATTR_URL,
DOMAIN, DOMAIN,
) )
from homeassistant.components.mealie.services import ( from homeassistant.components.mealie.services import (
SERVICE_GET_MEALPLAN, SERVICE_GET_MEALPLAN,
SERVICE_GET_RECIPE, SERVICE_GET_RECIPE,
SERVICE_IMPORT_RECIPE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@ -136,6 +143,47 @@ async def test_service_recipe(
assert response == snapshot 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( @pytest.mark.parametrize(
("exception", "raised_exception"), ("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( async def test_service_mealplan_connection_error(
hass: HomeAssistant, hass: HomeAssistant,
mock_mealie_client: AsyncMock, mock_mealie_client: AsyncMock,