diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 8cf15316121..0e1ea080f46 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo shoppinglist_coordinator = MealieShoppingListCoordinator(hass, client) await mealplan_coordinator.async_config_entry_first_refresh() - - await shoppinglist_coordinator.async_get_shopping_lists() await shoppinglist_coordinator.async_config_entry_first_refresh() entry.runtime_data = MealieData( diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index c7a00673929..bb97b3c26a3 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -96,8 +96,16 @@ class MealieMealplanCoordinator( return res +@dataclass +class ShoppingListData: + """Data class for shopping list data.""" + + shopping_list: ShoppingList + items: list[ShoppingItem] + + class MealieShoppingListCoordinator( - MealieDataUpdateCoordinator[dict[str, list[ShoppingItem]]] + MealieDataUpdateCoordinator[dict[str, ShoppingListData]] ): """Class to manage fetching Mealie Shopping list data.""" @@ -109,36 +117,25 @@ class MealieShoppingListCoordinator( client=client, update_interval=timedelta(minutes=5), ) - self.shopping_lists: list[ShoppingList] - - async def async_get_shopping_lists(self) -> list[ShoppingList]: - """Return shopping lists.""" - try: - self.shopping_lists = (await self.client.get_shopping_lists()).items - except MealieAuthenticationError as error: - raise ConfigEntryAuthFailed from error - except MealieConnectionError as error: - raise UpdateFailed(error) from error - return self.shopping_lists async def _async_update_data( self, - ) -> dict[str, list[ShoppingItem]]: - shopping_list_items: dict[str, list[ShoppingItem]] = {} - + ) -> dict[str, ShoppingListData]: + shopping_list_items = {} try: - for shopping_list in self.shopping_lists: + shopping_lists = (await self.client.get_shopping_lists()).items + for shopping_list in shopping_lists: shopping_list_id = shopping_list.list_id shopping_items = ( await self.client.get_shopping_items(shopping_list_id) ).items - shopping_list_items[shopping_list_id] = shopping_items - + shopping_list_items[shopping_list_id] = ShoppingListData( + shopping_list=shopping_list, items=shopping_items + ) except MealieAuthenticationError as error: raise ConfigEntryAuthFailed from error except MealieConnectionError as error: raise UpdateFailed(error) from error - return shopping_list_items diff --git a/homeassistant/components/mealie/diagnostics.py b/homeassistant/components/mealie/diagnostics.py index 8b457d1bbee..b1c8640f007 100644 --- a/homeassistant/components/mealie/diagnostics.py +++ b/homeassistant/components/mealie/diagnostics.py @@ -25,7 +25,7 @@ async def async_get_config_entry_diagnostics( for entry_type, mealplans in data.mealplan_coordinator.data.items() }, "shoppinglist": { - list_id: [asdict(item) for item in shopping_list] + list_id: asdict(shopping_list) for list_id, shopping_list in data.shoppinglist_coordinator.data.items() }, } diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index 7009dedf105..508b6aeb5e2 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -5,6 +5,7 @@ from __future__ import annotations from aiomealie import MealieError, MutateShoppingItem, ShoppingItem, ShoppingList from homeassistant.components.todo import ( + DOMAIN as TODO_DOMAIN, TodoItem, TodoItemStatus, TodoListEntity, @@ -12,6 +13,7 @@ from homeassistant.components.todo import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -48,10 +50,36 @@ async def async_setup_entry( """Set up the todo platform for entity.""" coordinator = entry.runtime_data.shoppinglist_coordinator - async_add_entities( - MealieShoppingListTodoListEntity(coordinator, shopping_list) - for shopping_list in coordinator.shopping_lists - ) + added_lists: set[str] = set() + + assert entry.unique_id is not None + + def _async_delete_entities(lists: set[str]) -> None: + """Delete entities for removed shopping lists.""" + entity_registry = er.async_get(hass) + for list_id in lists: + entity_id = entity_registry.async_get_entity_id( + TODO_DOMAIN, DOMAIN, f"{entry.unique_id}_{list_id}" + ) + if entity_id: + entity_registry.async_remove(entity_id) + + def _async_entity_listener() -> None: + """Handle additions/deletions of shopping lists.""" + received_lists = set(coordinator.data) + new_lists = received_lists - added_lists + removed_lists = added_lists - received_lists + if new_lists: + async_add_entities( + MealieShoppingListTodoListEntity(coordinator, shopping_list_id) + for shopping_list_id in new_lists + ) + added_lists.update(new_lists) + if removed_lists: + _async_delete_entities(removed_lists) + + coordinator.async_add_listener(_async_entity_listener) + _async_entity_listener() class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): @@ -69,17 +97,22 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): coordinator: MealieShoppingListCoordinator def __init__( - self, coordinator: MealieShoppingListCoordinator, shopping_list: ShoppingList + self, coordinator: MealieShoppingListCoordinator, shopping_list_id: str ) -> None: """Create the todo entity.""" - super().__init__(coordinator, shopping_list.list_id) - self._shopping_list = shopping_list - self._attr_name = shopping_list.name + super().__init__(coordinator, shopping_list_id) + self._shopping_list_id = shopping_list_id + self._attr_name = self.shopping_list.name + + @property + def shopping_list(self) -> ShoppingList: + """Get the shopping list.""" + return self.coordinator.data[self._shopping_list_id].shopping_list @property def shopping_items(self) -> list[ShoppingItem]: """Get the shopping items for this list.""" - return self.coordinator.data[self._shopping_list.list_id] + return self.coordinator.data[self._shopping_list_id].items @property def todo_items(self) -> list[TodoItem] | None: @@ -93,7 +126,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): position = self.shopping_items[-1].position + 1 new_shopping_item = MutateShoppingItem( - list_id=self._shopping_list.list_id, + list_id=self._shopping_list_id, note=item.summary.strip() if item.summary else item.summary, position=position, ) @@ -104,7 +137,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): translation_domain=DOMAIN, translation_key="add_item_error", translation_placeholders={ - "shopping_list_name": self._shopping_list.name + "shopping_list_name": self.shopping_list.name }, ) from exception finally: @@ -164,7 +197,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): translation_domain=DOMAIN, translation_key="update_item_error", translation_placeholders={ - "shopping_list_name": self._shopping_list.name + "shopping_list_name": self.shopping_list.name }, ) from exception finally: @@ -180,7 +213,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): translation_domain=DOMAIN, translation_key="delete_item_error", translation_placeholders={ - "shopping_list_name": self._shopping_list.name + "shopping_list_name": self.shopping_list.name }, ) from exception finally: @@ -238,6 +271,4 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): @property def available(self) -> bool: """Return False if shopping list no longer available.""" - return ( - super().available and self._shopping_list.list_id in self.coordinator.data - ) + return super().available and self._shopping_list_id in self.coordinator.data diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 16ce80c247f..e6c72c950cc 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -350,138 +350,156 @@ ]), }), 'shoppinglist': dict({ - '27edbaab-2ec6-441f-8490-0283ea77585f': list([ - dict({ - 'checked': False, - 'disable_amount': True, - 'display': '2 Apples', - 'food_id': None, - 'is_food': False, - 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': 'Apples', - 'position': 0, - 'quantity': 2.0, - 'unit_id': None, + '27edbaab-2ec6-441f-8490-0283ea77585f': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': True, + 'display': '2 Apples', + 'food_id': None, + 'is_food': False, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit_id': None, + }), + ]), + 'shopping_list': dict({ + 'list_id': '27edbaab-2ec6-441f-8490-0283ea77585f', + 'name': 'Supermarket', }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': '1 can acorn squash', - 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', - 'is_food': True, - 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 1, - 'quantity': 1.0, - 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + 'e9d78ff2-4b23-4b77-a3a8-464827100b46': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': True, + 'display': '2 Apples', + 'food_id': None, + 'is_food': False, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit_id': None, + }), + ]), + 'shopping_list': dict({ + 'list_id': 'e9d78ff2-4b23-4b77-a3a8-464827100b46', + 'name': 'Freezer', }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': 'aubergine', - 'food_id': '96801494-4e26-4148-849a-8155deb76327', - 'is_food': True, - 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 2, - 'quantity': 0.0, - 'unit_id': None, + }), + 'f8438635-8211-4be8-80d0-0aa42e37a5f2': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': True, + 'display': '2 Apples', + 'food_id': None, + 'is_food': False, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit_id': None, + }), + ]), + 'shopping_list': dict({ + 'list_id': 'f8438635-8211-4be8-80d0-0aa42e37a5f2', + 'name': 'Special groceries', }), - ]), - 'e9d78ff2-4b23-4b77-a3a8-464827100b46': list([ - dict({ - 'checked': False, - 'disable_amount': True, - 'display': '2 Apples', - 'food_id': None, - 'is_food': False, - 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': 'Apples', - 'position': 0, - 'quantity': 2.0, - 'unit_id': None, - }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': '1 can acorn squash', - 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', - 'is_food': True, - 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 1, - 'quantity': 1.0, - 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', - }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': 'aubergine', - 'food_id': '96801494-4e26-4148-849a-8155deb76327', - 'is_food': True, - 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 2, - 'quantity': 0.0, - 'unit_id': None, - }), - ]), - 'f8438635-8211-4be8-80d0-0aa42e37a5f2': list([ - dict({ - 'checked': False, - 'disable_amount': True, - 'display': '2 Apples', - 'food_id': None, - 'is_food': False, - 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': 'Apples', - 'position': 0, - 'quantity': 2.0, - 'unit_id': None, - }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': '1 can acorn squash', - 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', - 'is_food': True, - 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 1, - 'quantity': 1.0, - 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', - }), - dict({ - 'checked': False, - 'disable_amount': False, - 'display': 'aubergine', - 'food_id': '96801494-4e26-4148-849a-8155deb76327', - 'is_food': True, - 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, - 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', - 'note': '', - 'position': 2, - 'quantity': 0.0, - 'unit_id': None, - }), - ]), + }), }), }) # --- diff --git a/tests/components/mealie/test_init.py b/tests/components/mealie/test_init.py index 77041e1cecc..0050aa58bb8 100644 --- a/tests/components/mealie/test_init.py +++ b/tests/components/mealie/test_init.py @@ -135,25 +135,3 @@ async def test_shoppingitems_initialization_failure( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state - - -@pytest.mark.parametrize( - ("exc", "state"), - [ - (MealieConnectionError, ConfigEntryState.SETUP_ERROR), - (MealieAuthenticationError, ConfigEntryState.SETUP_ERROR), - ], -) -async def test_shoppinglists_initialization_failure( - hass: HomeAssistant, - mock_mealie_client: AsyncMock, - mock_config_entry: MockConfigEntry, - exc: Exception, - state: ConfigEntryState, -) -> None: - """Test initialization failure.""" - mock_mealie_client.get_shopping_lists.side_effect = exc - - await setup_integration(hass, mock_config_entry) - - assert mock_config_entry.state is state diff --git a/tests/components/mealie/test_todo.py b/tests/components/mealie/test_todo.py index 3ed33c78570..36bcaa05124 100644 --- a/tests/components/mealie/test_todo.py +++ b/tests/components/mealie/test_todo.py @@ -1,11 +1,15 @@ """Tests for the Mealie todo.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiomealie import ShoppingListsResponse from aiomealie.exceptions import MealieError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.mealie import DOMAIN from homeassistant.components.todo import ( ATTR_ITEM, ATTR_RENAME, @@ -20,7 +24,12 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) async def test_entities( @@ -153,3 +162,37 @@ async def test_delete_todo_list_item_error( target={ATTR_ENTITY_ID: "todo.mealie_supermarket"}, blocking=True, ) + + +async def test_runtime_management( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for creating and deleting shopping lists.""" + response = ShoppingListsResponse.from_json( + load_fixture("get_shopping_lists.json", DOMAIN) + ).items + mock_mealie_client.get_shopping_lists.return_value = ShoppingListsResponse( + items=[response[0]] + ) + await setup_integration(hass, mock_config_entry) + assert hass.states.get("todo.mealie_supermarket") is not None + assert hass.states.get("todo.mealie_special_groceries") is None + + mock_mealie_client.get_shopping_lists.return_value = ShoppingListsResponse( + items=response[0:2] + ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("todo.mealie_special_groceries") is not None + + mock_mealie_client.get_shopping_lists.return_value = ShoppingListsResponse( + items=[response[0]] + ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("todo.mealie_special_groceries") is None