From 76ebc134f34d7fa44c4c9cc22ef63d5297817bd4 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 16 Feb 2026 20:43:36 +0000 Subject: [PATCH] Mealie add get shopping list items action (#163090) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mealie/icons.json | 3 + homeassistant/components/mealie/services.py | 12 ++ homeassistant/components/mealie/services.yaml | 6 + homeassistant/components/mealie/strings.json | 7 + homeassistant/components/mealie/todo.py | 27 +++- .../mealie/snapshots/test_services.ambr | 153 ++++++++++++++++++ tests/components/mealie/test_services.py | 42 +++++ 7 files changed, 248 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 6a2afcdba3b..c7bc5e0772e 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -33,6 +33,9 @@ "get_recipes": { "service": "mdi:book-open-page-variant" }, + "get_shopping_list_items": { + "service": "mdi:basket" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f6ba4fea1b7..d1e4745bf59 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -12,6 +12,7 @@ from aiomealie import ( from awesomeversion import AwesomeVersion import voluptuous as vol +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, @@ -64,6 +65,8 @@ SERVICE_GET_RECIPES_SCHEMA = vol.Schema( } ) +SERVICE_GET_SHOPPING_LIST_ITEMS = "get_shopping_list_items" + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -321,3 +324,12 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + entity_domain=TODO_DOMAIN, + schema=None, + func="async_get_shopping_list_items", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 31181c0d091..6eef192dfab 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -45,6 +45,12 @@ get_recipes: mode: box unit_of_measurement: recipes +get_shopping_list_items: + target: + entity: + integration: mealie + domain: todo + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index c3b6dfd6992..2c337dee445 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -147,6 +147,9 @@ "setup_failed": { "message": "Could not connect to the Mealie instance." }, + "shopping_list_not_found": { + "message": "Shopping list with name or ID `{shopping_list}` not found." + }, "update_failed_mealplan": { "message": "Could not fetch mealplan data." }, @@ -227,6 +230,10 @@ }, "name": "Get recipes" }, + "get_shopping_list_items": { + "description": "Gets items from a shopping list in Mealie", + "name": "Get shopping list items" + }, "import_recipe": { "description": "Imports a recipe from an URL", "fields": { diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index c701af2865c..c504ba1e7f0 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -2,7 +2,15 @@ from __future__ import annotations -from aiomealie import MealieError, MutateShoppingItem, ShoppingItem, ShoppingList +from dataclasses import asdict + +from aiomealie import ( + MealieConnectionError, + MealieError, + MutateShoppingItem, + ShoppingItem, + ShoppingList, +) from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, @@ -11,7 +19,7 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -265,3 +273,18 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): def available(self) -> bool: """Return False if shopping list no longer available.""" return super().available and self._shopping_list_id in self.coordinator.data + + async def async_get_shopping_list_items(self) -> ServiceResponse: + """Get structured shopping list items.""" + client = self.coordinator.client + try: + shopping_items = await client.get_shopping_items(self._shopping_list_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return { + "name": self.shopping_list.name, + "items": [asdict(item) for item in shopping_items.items], + } diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 6cc2e60882b..1b3506e9422 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1637,6 +1637,159 @@ }), }) # --- +# name: test_service_get_shopping_list_items + dict({ + 'todo.mealie_supermarket': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '2 Apples', + 'food': None, + 'food_id': None, + 'is_food': None, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454134), + 'description': '', + 'extras': dict({ + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'acorn squash', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454141), + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': '', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464122), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'can', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464124), + 'use_abbreviation': False, + }), + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868792), + 'description': '', + 'extras': dict({ + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'aubergine', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868794), + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '1 US cup flour', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40354, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'households_with_ingredient_food': list([ + ]), + 'label': None, + 'label_id': None, + 'name': 'flour', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40371, tzinfo=datetime.timezone.utc), + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'is_food': None, + 'item_id': '22b389bb-e079-481c-915d-394e5edb20a5', + 'label': dict({ + 'label_id': '0e55cae5-6037-4cbb-8d4f-1042cbb83fd0', + 'name': 'Household', + }), + 'label_id': None, + 'list_id': 'a33af640-4704-453c-ab03-a95a393bf1c4', + 'note': '', + 'position': 0, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': 'US cup', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477518, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'US cup', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477535, tzinfo=datetime.timezone.utc), + 'use_abbreviation': False, + }), + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + }), + ]), + 'name': 'Supermarket', + }), + }) +# --- # name: test_service_import_recipe dict({ 'recipe': dict({ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 0c31d783cee..957a219f901 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -31,6 +31,7 @@ from homeassistant.components.mealie.services import ( SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, SERVICE_GET_RECIPES, + SERVICE_GET_SHOPPING_LIST_ITEMS, SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, @@ -395,6 +396,47 @@ async def test_service_set_mealplan_invalid_entry_type( mock_mealie_client.set_mealplan.assert_not_called() +async def test_service_get_shopping_list_items( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_shopping_list_items service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_service_get_shopping_list_items_connection_error( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_shopping_list_items service with connection error.""" + + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.get_shopping_items.side_effect = MealieConnectionError + + with pytest.raises(HomeAssistantError, match="Error connecting to Mealie instance"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [