diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index a7d26ceb5c6..ec7f6e15425 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN from .coordinator import PicnicUpdateCoordinator +from .services import async_register_services PLATFORMS = [Platform.SENSOR] @@ -36,6 +37,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register the services + await async_register_services(hass) + return True diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index f33f58c0eb9..85a7acadaeb 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -17,6 +17,14 @@ CONF_API = "api" CONF_COORDINATOR = "coordinator" CONF_COUNTRY_CODE = "country_code" +SERVICE_ADD_PRODUCT_TO_CART = "add_product" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_PRODUCT_ID = "product_id" +ATTR_PRODUCT_NAME = "product_name" +ATTR_AMOUNT = "amount" +ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" + COUNTRY_CODES = ["NL", "DE", "BE"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py new file mode 100644 index 00000000000..565905c76fe --- /dev/null +++ b/homeassistant/components/picnic/services.py @@ -0,0 +1,90 @@ +"""Services for the Picnic integration.""" +from __future__ import annotations + +from python_picnic_api import PicnicAPI +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_AMOUNT, + ATTR_CONFIG_ENTRY_ID, + ATTR_PRODUCT_ID, + ATTR_PRODUCT_IDENTIFIERS, + ATTR_PRODUCT_NAME, + CONF_API, + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, +) + + +class PicnicServiceException(Exception): + """Exception for Picnic services.""" + + +async def async_register_services(hass: HomeAssistant) -> None: + """Register services for the Picnic integration, if not registered yet.""" + + if hass.services.has_service(DOMAIN, SERVICE_ADD_PRODUCT_TO_CART): + return + + async def async_add_product_service(call: ServiceCall): + api_client = await get_api_client(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + await handle_add_product(hass, api_client, call) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + async_add_product_service, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Exclusive( + ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS + ): cv.positive_int, + vol.Exclusive(ATTR_PRODUCT_NAME, ATTR_PRODUCT_IDENTIFIERS): cv.string, + vol.Optional(ATTR_AMOUNT): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + ) + + +async def get_api_client(hass: HomeAssistant, config_entry_id: str) -> PicnicAPI: + """Get the right Picnic API client based on the device id, else get the default one.""" + if config_entry_id not in hass.data[DOMAIN]: + raise ValueError(f"Config entry with id {config_entry_id} not found!") + return hass.data[DOMAIN][config_entry_id][CONF_API] + + +async def handle_add_product( + hass: HomeAssistant, api_client: PicnicAPI, call: ServiceCall +) -> None: + """Handle the call for the add_product service.""" + product_id = call.data.get("product_id") + if not product_id: + product_id = await hass.async_add_executor_job( + _product_search, api_client, call.data.get("product_name") + ) + + if not product_id: + raise PicnicServiceException("No product found or no product ID given!") + + await hass.async_add_executor_job( + api_client.add_product, str(product_id), call.data.get("amount", 1) + ) + + +def _product_search(api_client: PicnicAPI, product_name: str) -> None | str: + """Query the api client for the product name.""" + search_result = api_client.search(product_name) + + if not search_result or "items" not in search_result[0]: + return None + + # Return the first valid result + for item in search_result[0]["items"]: + if "name" in item: + return str(item["id"]) + + return None diff --git a/homeassistant/components/picnic/services.yaml b/homeassistant/components/picnic/services.yaml new file mode 100644 index 00000000000..9af2cb48291 --- /dev/null +++ b/homeassistant/components/picnic/services.yaml @@ -0,0 +1,37 @@ +add_product: + name: Add a product to the cart + description: >- + Adds a product to the cart based on a search string or product ID. + The search string and product ID are exclusive. + + fields: + config_entry_id: + name: Picnic service + description: The product will be added to the selected service. + required: true + selector: + config_entry: + integration: picnic + product_id: + name: Product ID + description: The product ID of a Picnic product. + required: false + example: "10510201" + selector: + text: + product_name: + name: Product name + description: Search for a product and add the first result + required: false + example: "Yoghurt" + selector: + text: + amount: + name: Amount + description: Amount to add of the selected product + required: false + default: 1 + selector: + number: + min: 1 + max: 50 diff --git a/tests/components/picnic/test_services.py b/tests/components/picnic/test_services.py new file mode 100644 index 00000000000..ab7ff859d6e --- /dev/null +++ b/tests/components/picnic/test_services.py @@ -0,0 +1,211 @@ +"""Tests for the Picnic services.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.components.picnic.const import SERVICE_ADD_PRODUCT_TO_CART +from homeassistant.components.picnic.services import PicnicServiceException +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +UNIQUE_ID = "295-6y3-1nf4" + + +def create_picnic_api_client(unique_id): + """Create PicnicAPI mock with set response data.""" + auth_token = "af3wh738j3fa28l9fa23lhiufahu7l" + auth_data = { + "user_id": unique_id, + "address": { + "street": "Teststreet", + "house_number": 123, + "house_number_ext": "b", + }, + } + picnic_mock = MagicMock() + picnic_mock.session.auth_token = auth_token + picnic_mock.get_user.return_value = auth_data + + return picnic_mock + + +async def create_picnic_config_entry(hass: HomeAssistant, unique_id): + """Create a Picnic config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCESS_TOKEN: "x-original-picnic-auth-token", + CONF_COUNTRY_CODE: "NL", + }, + unique_id=unique_id, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture +def picnic_api_client(): + """Return the default picnic api client.""" + with patch( + "homeassistant.components.picnic.create_picnic_client" + ) as create_picnic_client_mock: + picnic_client_mock = create_picnic_api_client(UNIQUE_ID) + create_picnic_client_mock.return_value = picnic_client_mock + + yield picnic_client_mock + + +@pytest.fixture +async def picnic_config_entry(hass: HomeAssistant): + """Generate the default Picnic config entry.""" + return await create_picnic_config_entry(hass, UNIQUE_ID) + + +async def test_add_product_using_id( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product by id.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + { + "config_entry_id": picnic_config_entry.entry_id, + "product_id": "5109348572", + "amount": 3, + }, + blocking=True, + ) + + # Check that the right method is called on the api + picnic_api_client.add_product.assert_called_with("5109348572", 3) + + +async def test_add_product_using_name( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product by name.""" + + # Set the return value of the search api endpoint + picnic_api_client.search.return_value = [ + { + "items": [ + { + "id": "2525404", + "name": "Best tea", + "display_price": 321, + "unit_quantity": "big bags", + }, + { + "id": "2525500", + "name": "Cheap tea", + "display_price": 100, + "unit_quantity": "small bags", + }, + ] + } + ] + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + {"config_entry_id": picnic_config_entry.entry_id, "product_name": "Tea"}, + blocking=True, + ) + + # Check that the right method is called on the api + picnic_api_client.add_product.assert_called_with("2525404", 1) + + +async def test_add_product_using_name_no_results( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product by name that can't be found.""" + + # Set the search return value and check that the right exception is raised during the service call + picnic_api_client.search.return_value = [] + with pytest.raises(PicnicServiceException): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + { + "config_entry_id": picnic_config_entry.entry_id, + "product_name": "Random non existing product", + }, + blocking=True, + ) + + +async def test_add_product_using_name_no_named_results( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product by name for which no named results are returned.""" + + # Set the search return value and check that the right exception is raised during the service call + picnic_api_client.search.return_value = [{"items": [{"attr": "test"}]}] + with pytest.raises(PicnicServiceException): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + { + "config_entry_id": picnic_config_entry.entry_id, + "product_name": "Random product", + }, + blocking=True, + ) + + +async def test_add_product_multiple_config_entries( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product for a specific Picnic service while multiple are configured.""" + with patch( + "homeassistant.components.picnic.create_picnic_client" + ) as create_picnic_client_mock: + picnic_api_client_2 = create_picnic_api_client("3fj9-9gju-236") + create_picnic_client_mock.return_value = picnic_api_client_2 + picnic_config_entry_2 = await create_picnic_config_entry(hass, "3fj9-9gju-236") + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + {"product_id": "5109348572", "config_entry_id": picnic_config_entry_2.entry_id}, + blocking=True, + ) + + # Check that the right method is called on the api + picnic_api_client.add_product.assert_not_called() + picnic_api_client_2.add_product.assert_called_with("5109348572", 1) + + +async def test_add_product_device_doesnt_exist( + hass: HomeAssistant, + picnic_api_client: MagicMock, + picnic_config_entry: MockConfigEntry, +): + """Test adding a product for a specific Picnic service, which doesn't exist.""" + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRODUCT_TO_CART, + {"product_id": "5109348572", "config_entry_id": 12345}, + blocking=True, + ) + + # Check that the right method is called on the api + picnic_api_client.add_product.assert_not_called()