diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index ec7f6e15425..6826d8940ab 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -10,7 +10,7 @@ from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN from .coordinator import PicnicUpdateCoordinator from .services import async_register_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.TODO] def create_picnic_client(entry: ConfigEntry): diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index 0fd107609d1..9a6b7162fd5 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -21,6 +21,11 @@ } }, "entity": { + "todo": { + "shopping_cart": { + "name": "Shopping cart" + } + }, "sensor": { "cart_items_count": { "name": "Cart items count" diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py new file mode 100644 index 00000000000..8210702e826 --- /dev/null +++ b/homeassistant/components/picnic/todo.py @@ -0,0 +1,75 @@ +"""Definition of Picnic shopping cart.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Picnic shopping cart todo platform config entry.""" + picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + + # Add an entity shopping card + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) + + +class PicnicCart(TodoListEntity, CoordinatorEntity): + """A Picnic Shopping Cart TodoListEntity.""" + + _attr_has_entity_name = True + _attr_translation_key = "shopping_cart" + _attr_icon = "mdi:cart" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + config_entry: ConfigEntry, + ) -> None: + """Initialize PicnicCart.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + ) + self._attr_unique_id = f"{config_entry.unique_id}-cart" + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of items in cart items.""" + if self.coordinator.data is None: + return None + + _LOGGER.debug(self.coordinator.data["cart_data"]["items"]) + + items = [] + for item in self.coordinator.data["cart_data"]["items"]: + for article in item["items"]: + items.append( + TodoItem( + summary=f"{article['name']} ({article['unit_quantity']})", + uid=f"{item['id']}-{article['id']}", + status=TodoItemStatus.NEEDS_ACTION, # We set 'NEEDS_ACTION' so they count as state + ) + ) + + return items diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py new file mode 100644 index 00000000000..7e36371767d --- /dev/null +++ b/tests/components/picnic/conftest.py @@ -0,0 +1,52 @@ +"""Conftest for Picnic tests.""" +import json +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCESS_TOKEN: "x-original-picnic-auth-token", + CONF_COUNTRY_CODE: "NL", + }, + unique_id="295-6y3-1nf4", + ) + + +@pytest.fixture +def mock_picnic_api(): + """Return a mocked PicnicAPI client.""" + with patch("homeassistant.components.picnic.PicnicAPI") as mock: + client = mock.return_value + client.session.auth_token = "3q29fpwhulzes" + client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) + client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) + client.get_deliveries.return_value = json.loads( + load_fixture("picnic/delivery.json") + ) + client.get_delivery_position.return_value = {} + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock +) -> MockConfigEntry: + """Set up the Picnic integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/picnic/fixtures/cart.json b/tests/components/picnic/fixtures/cart.json new file mode 100644 index 00000000000..bde170bb26a --- /dev/null +++ b/tests/components/picnic/fixtures/cart.json @@ -0,0 +1,337 @@ +{ + "items": [ + { + "type": "ORDER_LINE", + "id": "763", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1001194", + "name": "Knoflook", + "image_ids": [ + "4054013cb82da80abbdcd7c8eec54f486bfa180b9cf499e94cc4013470d0dfd7" + ], + "unit_quantity": "2 stuks", + "unit_quantity_sub": "€9.08/kg", + "price": 109, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 stuks" + } + ] + } + ], + "display_price": 109, + "price": 109 + }, + { + "type": "ORDER_LINE", + "id": "765_766", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1046297", + "name": "Picnic magere melk", + "image_ids": [ + "c2a96757634ada380726d3307e564f244cfa86e89d94c2c0e382306dbad599a3" + ], + "unit_quantity": "2 x 1 liter", + "unit_quantity_sub": "€1.02/l", + "price": 204, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 2 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 x 1 liter" + } + ] + } + ], + "display_price": 408, + "price": 408 + }, + { + "type": "ORDER_LINE", + "id": "767", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1010532", + "name": "Picnic magere melk", + "image_ids": [ + "aa8880361f045ffcfb9f787e9b7fc2b49907be46921bf42985506dc03baa6c2c" + ], + "unit_quantity": "1 liter", + "unit_quantity_sub": "€1.05/l", + "price": 105, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "1 liter" + } + ] + } + ], + "display_price": 105, + "price": 105 + }, + { + "type": "ORDER_LINE", + "id": "774_775", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1018253", + "name": "Robijn wascapsules wit", + "image_ids": [ + "c78b809ccbcd65760f8ce897e083587ee7b3f2b9719affd80983fad722b5c2d9" + ], + "unit_quantity": "40 wasbeurten", + "price": 2899, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "40 wasbeurten" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1007025", + "name": "Robijn wascapsules kleur", + "image_ids": [ + "ef9c8a371a639906ef20dfdcdc99296fce4102c47f0018e6329a2e4ae9f846b7" + ], + "unit_quantity": "15 wasbeurten", + "price": 879, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "15 wasbeurten" + } + ] + } + ], + "display_price": 3778, + "price": 3778, + "decorators": [ + { + "type": "PROMO", + "text": "1+1 gratis" + }, + { + "type": "PRICE", + "display_price": 1889 + } + ] + }, + { + "type": "ORDER_LINE", + "id": "776_777_778_779_780", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1012699", + "name": "Chinese wokgroenten", + "image_ids": [ + "b0b547a03d1d6021565618a5d32bd35df34c57b348d73252defb776ab8f8ab76" + ], + "unit_quantity": "600 gram", + "unit_quantity_sub": "€4.92/kg", + "price": 295, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "600 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1003425", + "name": "Picnic boerderij-eitjes", + "image_ids": [ + "8be72b8144bfb7ff637d4703cfcb11e1bee789de79c069d00e879650dbf19840" + ], + "unit_quantity": "6 stuks M/L", + "price": 305, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "6 stuks M/L" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1016692", + "name": "Picnic witte snelkookrijst", + "image_ids": [ + "9c76c0a0143bfef650ab85fff4f0918e0b4e2927d79caa2a2bf394f292a86213" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€3.23/kg", + "price": 129, + "max_count": 99, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1012503", + "name": "Conimex kruidenmix nasi", + "image_ids": [ + "2eb78de465aa327a9739d9b204affce17fdf6bf7675c4fe9fa2d4ec102791c69" + ], + "unit_quantity": "20 gram", + "unit_quantity_sub": "€42.50/kg", + "price": 85, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "20 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1005028", + "name": "Conimex satésaus mild kant & klaar", + "image_ids": [ + "0273de24577ba25526cdf31c53ef2017c62611b2bb4d82475abb2dcd9b2f5b83" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€5.98/kg", + "price": 239, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + } + ], + "display_price": 1053, + "price": 1053, + "decorators": [ + { + "type": "PROMO", + "text": "Receptkorting" + }, + { + "type": "PRICE", + "display_price": 880 + } + ] + } + ], + "delivery_slots": [ + { + "slot_id": "611a3b074872b23576bef456a", + "window_start": "2021-03-03T14:45:00.000+01:00", + "window_end": "2021-03-03T15:45:00.000+01:00", + "cut_off_time": "2021-03-02T22:00:00.000+01:00", + "minimum_order_value": 3500 + } + ], + "selected_slot": { + "slot_id": "611a3b074872b23576bef456a", + "state": "EXPLICIT" + }, + "total_count": 10, + "total_price": 2535 +} diff --git a/tests/components/picnic/fixtures/delivery.json b/tests/components/picnic/fixtures/delivery.json new file mode 100644 index 00000000000..61a7fe7ac35 --- /dev/null +++ b/tests/components/picnic/fixtures/delivery.json @@ -0,0 +1,31 @@ +{ + "delivery_id": "z28fjso23e", + "creation_time": "2021-02-24T21:48:46.395+01:00", + "slot": { + "slot_id": "602473859a40dc24c6b65879", + "hub_id": "AMS", + "window_start": "2021-02-26T20:15:00.000+01:00", + "window_end": "2021-02-26T21:15:00.000+01:00", + "cut_off_time": "2021-02-25T22:00:00.000+01:00", + "minimum_order_value": 3500 + }, + "eta2": { + "start": "2021-02-26T20:54:00.000+01:00", + "end": "2021-02-26T21:14:00.000+01:00" + }, + "status": "COMPLETED", + "delivery_time": { + "start": "2021-02-26T20:54:05.221+01:00", + "end": "2021-02-26T20:58:31.802+01:00" + }, + "orders": [ + { + "creation_time": "2021-02-24T21:48:46.418+01:00", + "total_price": 3597 + }, + { + "creation_time": "2021-02-25T17:10:26.816+01:00", + "total_price": 536 + } + ] +} diff --git a/tests/components/picnic/fixtures/user.json b/tests/components/picnic/fixtures/user.json new file mode 100644 index 00000000000..3656d11e98c --- /dev/null +++ b/tests/components/picnic/fixtures/user.json @@ -0,0 +1,14 @@ +{ + "user_id": "295-6y3-1nf4", + "firstname": "User", + "lastname": "Name", + "address": { + "house_number": 123, + "house_number_ext": "a", + "postcode": "4321 AB", + "street": "Commonstreet", + "city": "Somewhere" + }, + "total_deliveries": 123, + "completed_deliveries": 112 +} diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py new file mode 100644 index 00000000000..675651dc588 --- /dev/null +++ b/tests/components/picnic/test_todo.py @@ -0,0 +1,54 @@ +"""Tests for Picnic Tasks todo platform.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_cart_list_with_items(hass: HomeAssistant, init_integration) -> None: + """Test loading of shopping cart.""" + state = hass.states.get("todo.mock_title_shopping_cart") + assert state + assert state.state == "10" + + +async def test_cart_list_empty_items( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without items.""" + mock_picnic_api.get_cart.return_value = {"items": []} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state + assert state.state == "0" + + +async def test_cart_list_unexpected_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without expected response.""" + mock_picnic_api.get_cart.return_value = {} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state is None + + +async def test_cart_list_null_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without response.""" + mock_picnic_api.get_cart.return_value = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("todo.mock_title_shopping_cart") + assert state is None