Dynamically create and delete todo lists in mealie (#121710)

This commit is contained in:
Joost Lekkerkerker 2024-07-11 10:19:45 +02:00 committed by GitHub
parent c223709c7c
commit 73475aa675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 255 additions and 190 deletions

View file

@ -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(

View file

@ -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

View file

@ -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()
},
}

View file

@ -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

View file

@ -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,
}),
]),
}),
}),
})
# ---

View file

@ -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

View file

@ -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