diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 0f7afe3240e..af9af171b4a 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -25,6 +25,7 @@ from .const import ( SERVICE_COMPLETE_ITEM, SERVICE_INCOMPLETE_ALL, SERVICE_INCOMPLETE_ITEM, + SERVICE_REMOVE_ITEM, ) ATTR_COMPLETE = "complete" @@ -34,11 +35,12 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) PERSISTENCE = ".shopping_list.json" -SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): vol.Any(None, cv.string)}) +SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) WS_TYPE_SHOPPING_LIST_ITEMS = "shopping_list/items" WS_TYPE_SHOPPING_LIST_ADD_ITEM = "shopping_list/items/add" +WS_TYPE_SHOPPING_LIST_REMOVE_ITEM = "shopping_list/items/remove" WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = "shopping_list/items/update" WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS = "shopping_list/items/clear" @@ -50,6 +52,13 @@ SCHEMA_WEBSOCKET_ADD_ITEM = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): WS_TYPE_SHOPPING_LIST_ADD_ITEM, vol.Required("name"): str} ) +SCHEMA_WEBSOCKET_REMOVE_ITEM = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_SHOPPING_LIST_REMOVE_ITEM, + vol.Required("item_id"): str, + } +) + SCHEMA_WEBSOCKET_UPDATE_ITEM = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( { vol.Required("type"): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, @@ -85,26 +94,37 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def add_item_service(call: ServiceCall) -> None: """Add an item with `name`.""" data = hass.data[DOMAIN] - if (name := call.data.get(ATTR_NAME)) is not None: - await data.async_add(name) + await data.async_add(call.data[ATTR_NAME]) - async def complete_item_service(call: ServiceCall) -> None: - """Mark the item provided via `name` as completed.""" + async def remove_item_service(call: ServiceCall) -> None: + """Remove the first item with matching `name`.""" data = hass.data[DOMAIN] - if (name := call.data.get(ATTR_NAME)) is None: - return + name = call.data[ATTR_NAME] + try: item = [item for item in data.items if item["name"] == name][0] except IndexError: _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + await data.async_remove(item["id"]) + + async def complete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as completed.""" + data = hass.data[DOMAIN] + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + except IndexError: + _LOGGER.error("Updating of item failed: %s cannot be found", name) else: await data.async_update(item["id"], {"name": name, "complete": True}) async def incomplete_item_service(call: ServiceCall) -> None: - """Mark the item provided via `name` as incomplete.""" + """Mark the first item with matching `name` as incomplete.""" data = hass.data[DOMAIN] - if (name := call.data.get(ATTR_NAME)) is None: - return + name = call.data[ATTR_NAME] + try: item = [item for item in data.items if item["name"] == name][0] except IndexError: @@ -130,6 +150,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.services.async_register( DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA + ) hass.services.async_register( DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA ) @@ -179,6 +202,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_handle_add, SCHEMA_WEBSOCKET_ADD_ITEM, ) + websocket_api.async_register_command( + hass, + WS_TYPE_SHOPPING_LIST_REMOVE_ITEM, + websocket_handle_remove, + SCHEMA_WEBSOCKET_REMOVE_ITEM, + ) websocket_api.async_register_command( hass, WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, @@ -217,6 +246,22 @@ class ShoppingData: ) return item + async def async_remove(self, item_id, context=None): + """Remove a shopping list item.""" + item = next((itm for itm in self.items if itm["id"] == item_id), None) + + if item is None: + raise KeyError + + self.items.remove(item) + await self.hass.async_add_executor_job(self.save) + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "remove", "item": item}, + context=context, + ) + return item + async def async_update(self, item_id, info, context=None): """Update a shopping list item.""" item = next((itm for itm in self.items if itm["id"] == item_id), None) @@ -363,7 +408,7 @@ def websocket_handle_items( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Handle get shopping_list items.""" + """Handle getting shopping_list items.""" connection.send_message( websocket_api.result_message(msg["id"], hass.data[DOMAIN].items) ) @@ -375,18 +420,38 @@ async def websocket_handle_add( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Handle add item to shopping_list.""" + """Handle adding item to shopping_list.""" item = await hass.data[DOMAIN].async_add(msg["name"], connection.context(msg)) connection.send_message(websocket_api.result_message(msg["id"], item)) +@websocket_api.async_response +async def websocket_handle_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle removing shopping_list item.""" + msg_id = msg.pop("id") + item_id = msg.pop("item_id") + msg.pop("type") + + try: + item = await hass.data[DOMAIN].async_remove(item_id, connection.context(msg)) + connection.send_message(websocket_api.result_message(msg_id, item)) + except KeyError: + connection.send_message( + websocket_api.error_message(msg_id, "item_not_found", "Item not found") + ) + + @websocket_api.async_response async def websocket_handle_update( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Handle update shopping_list item.""" + """Handle updating shopping_list item.""" msg_id = msg.pop("id") item_id = msg.pop("item_id") msg.pop("type") diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index fffc1064226..05dc05137c0 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -3,6 +3,7 @@ DOMAIN = "shopping_list" EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" SERVICE_ADD_ITEM = "add_item" +SERVICE_REMOVE_ITEM = "remove_item" SERVICE_COMPLETE_ITEM = "complete_item" SERVICE_INCOMPLETE_ITEM = "incomplete_item" SERVICE_COMPLETE_ALL = "complete_all" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index 0af388cfcb1..c41bc1333dc 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -10,9 +10,21 @@ add_item: selector: text: +remove_item: + name: Remove item + description: Remove the first item with matching name from the shopping list. + fields: + name: + name: Name + description: The name of the item to remove. + required: true + example: Beer + selector: + text: + complete_item: name: Complete item - description: Mark an item as completed in the shopping list. + description: Mark the first item with matching name as completed in the shopping list. fields: name: name: Name @@ -24,7 +36,7 @@ complete_item: incomplete_item: name: Incomplete item - description: Marks an item as incomplete in the shopping list. + description: Mark the first item with matching name as incomplete in the shopping list. fields: name: description: The name of the item to mark as incomplete. @@ -35,11 +47,11 @@ incomplete_item: complete_all: name: Complete all - description: Marks all items as completed in the shopping list. It does not remove the items. + description: Mark all items as completed in the shopping list (without removing them from the list). incomplete_all: name: Incomplete all - description: Marks all items as incomplete in the shopping list. + description: Mark all items as incomplete in the shopping list. clear_completed_items: name: Clear completed items diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 515d0818460..9b10d84df36 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,12 +1,15 @@ """Test shopping list component.""" from http import HTTPStatus +import pytest + from homeassistant.components.shopping_list.const import ( DOMAIN, EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, SERVICE_CLEAR_COMPLETED_ITEMS, SERVICE_COMPLETE_ITEM, + SERVICE_REMOVE_ITEM, ) from homeassistant.components.websocket_api.const import ( ERR_INVALID_FORMAT, @@ -29,6 +32,32 @@ async def test_add_item(hass, sl_setup): assert response.speech["plain"]["speech"] == "I've added beer to your shopping list" +async def test_remove_item(hass, sl_setup): + """Test removiung list items.""" + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} + ) + + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} + ) + + assert len(hass.data[DOMAIN].items) == 2 + + # Remove a single item + item_id = hass.data[DOMAIN].items[0]["id"] + await hass.data[DOMAIN].async_remove(item_id) + + assert len(hass.data[DOMAIN].items) == 1 + + item = hass.data[DOMAIN].items[0] + assert item["name"] == "cheese" + + # Trying to remove the same item twice should fail + with pytest.raises(KeyError): + await hass.data[DOMAIN].async_remove(item_id) + + async def test_update_list(hass, sl_setup): """Test updating all list items.""" await intent.async_handle( @@ -414,6 +443,47 @@ async def test_ws_add_item_fail(hass, hass_ws_client, sl_setup): assert len(hass.data["shopping_list"].items) == 0 +async def test_ws_remove_item(hass, hass_ws_client, sl_setup): + """Test removing shopping_list item websocket command.""" + client = await hass_ws_client(hass) + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + await client.send_json({"id": 5, "type": "shopping_list/items/add", "name": "soda"}) + msg = await client.receive_json() + first_item_id = msg["result"]["id"] + await client.send_json( + {"id": 6, "type": "shopping_list/items/add", "name": "cheese"} + ) + msg = await client.receive_json() + assert len(events) == 2 + + items = hass.data["shopping_list"].items + assert len(items) == 2 + + await client.send_json( + {"id": 7, "type": "shopping_list/items/remove", "item_id": first_item_id} + ) + msg = await client.receive_json() + assert len(events) == 3 + assert msg["success"] is True + + items = hass.data["shopping_list"].items + assert len(items) == 1 + assert items[0]["name"] == "cheese" + + +async def test_ws_remove_item_fail(hass, hass_ws_client, sl_setup): + """Test removing shopping_list item failure websocket command.""" + client = await hass_ws_client(hass) + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + await client.send_json({"id": 5, "type": "shopping_list/items/add", "name": "soda"}) + msg = await client.receive_json() + await client.send_json({"id": 6, "type": "shopping_list/items/remove"}) + msg = await client.receive_json() + assert msg["success"] is False + assert len(events) == 1 + assert len(hass.data["shopping_list"].items) == 1 + + async def test_ws_reorder_items(hass, hass_ws_client, sl_setup): """Test reordering shopping_list items websocket command.""" await intent.async_handle( @@ -558,6 +628,40 @@ async def test_add_item_service(hass, sl_setup): assert len(events) == 1 +async def test_remove_item_service(hass, sl_setup): + """Test removing shopping_list item service.""" + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "cheese"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.data[DOMAIN].items) == 2 + assert len(events) == 2 + + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.data[DOMAIN].items) == 1 + assert hass.data[DOMAIN].items[0]["name"] == "cheese" + assert len(events) == 3 + + async def test_clear_completed_items_service(hass, sl_setup): """Test clearing completed shopping_list items service.""" events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)