diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index a1379b003f6..883f7fac6f1 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -12,25 +12,29 @@ from .const import DATA_COMPONENT, DOMAIN INTENT_LIST_ADD_ITEM = "HassListAddItem" INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem" +INTENT_LIST_REMOVE_ITEM = "HassListRemoveItem" async def async_setup_intents(hass: HomeAssistant) -> None: - """Set up the todo intents.""" - intent.async_register(hass, ListAddItemIntent()) - intent.async_register(hass, ListCompleteItemIntent()) + """Set up the todo intent handlers.""" + intent.async_register(hass, ListAddItemIntentHandler()) + intent.async_register(hass, ListCompleteItemIntentHandler()) + intent.async_register(hass, ListRemoveItemIntentHandler()) -class ListAddItemIntent(intent.IntentHandler): - """Handle ListAddItem intents.""" +class ListBaseIntentHandler(intent.IntentHandler): + """Base class for toto intent handlers.""" - intent_type = INTENT_LIST_ADD_ITEM - description = "Add item to a todo list" slot_schema = { vol.Required("item"): intent.non_empty_string, vol.Required("name"): intent.non_empty_string, } platforms = {DOMAIN} + async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None: + """Execute action specific to this intent handler.""" + raise NotImplementedError + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" hass = intent_obj.hass @@ -59,62 +63,46 @@ class ListAddItemIntent(intent.IntentHandler): f"No to-do list: {list_name}", "list_not_found" ) - # Add to list - await target_list.async_create_todo_item( - TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) - ) + # Execute specific action + await self._async_do_handle(target_list, item) + # Build intent response response: intent.IntentResponse = intent_obj.create_response() response.async_set_results( [ intent.IntentResponseTarget( type=intent.IntentResponseTargetType.ENTITY, name=list_name, - id=match_result.states[0].entity_id, + id=target_list.entity_id, ) ] ) return response -class ListCompleteItemIntent(intent.IntentHandler): +class ListAddItemIntentHandler(ListBaseIntentHandler): + """Handle ListAddItem intents.""" + + intent_type = INTENT_LIST_ADD_ITEM + description = "Add item to a todo list" + + async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None: + """Execute action specific to this intent handler.""" + + # Add to list + await target_list.async_create_todo_item( + TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) + ) + + +class ListCompleteItemIntentHandler(ListBaseIntentHandler): """Handle ListCompleteItem intents.""" intent_type = INTENT_LIST_COMPLETE_ITEM description = "Complete item on a todo list" - slot_schema = { - vol.Required("item"): intent.non_empty_string, - vol.Required("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - - slots = self.async_validate_slots(intent_obj.slots) - item = slots["item"]["value"] - list_name = slots["name"]["value"] - - target_list: TodoListEntity | None = None - - # Find matching list - match_constraints = intent.MatchTargetsConstraints( - name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - target_list = hass.data[DATA_COMPONENT].get_entity( - match_result.states[0].entity_id - ) - if target_list is None: - raise intent.IntentHandleError( - f"No to-do list: {list_name}", "list_not_found" - ) + async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None: + """Execute action specific to this intent handler.""" # Find item in list matching_item = None @@ -139,14 +127,26 @@ class ListCompleteItemIntent(intent.IntentHandler): ) ) - response: intent.IntentResponse = intent_obj.create_response() - response.async_set_results( - [ - intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.ENTITY, - name=list_name, - id=match_result.states[0].entity_id, - ) - ] - ) - return response + +class ListRemoveItemIntentHandler(ListBaseIntentHandler): + """Handle LisRemoveItemIntent intents.""" + + intent_type = INTENT_LIST_REMOVE_ITEM + description = "Remove one or more items from a todo list" + + async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None: + """Execute action specific to this intent handler.""" + + # Find item in list + matching_item = None + for todo_item in target_list.todo_items or (): + if item in (todo_item.uid, todo_item.summary): + matching_item = todo_item + break + if not matching_item or not matching_item.uid: + raise intent.IntentHandleError( + f"Item '{item}' not found on list", "item_not_found" + ) + + # Remove items + await target_list.async_delete_todo_items(uids=[matching_item.uid]) diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 7d11e2cfbb5..50cceb5f682 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -439,7 +439,7 @@ async def test_todo_add_item_fr( with ( patch.object(hass.config, "language", "fr"), patch( - "homeassistant.components.todo.intent.ListAddItemIntent.async_handle", + "homeassistant.components.todo.intent.ListAddItemIntentHandler.async_handle", return_value=intent.IntentResponse(hass.config.language), ) as mock_handle, ): diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py index 3f86347d1b7..0b7ba0c628d 100644 --- a/tests/components/todo/test_intent.py +++ b/tests/components/todo/test_intent.py @@ -290,3 +290,80 @@ async def test_complete_item_intent_ha_errors( {ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}}, assistant=conversation.DOMAIN, ) + + +async def test_remove_item_intent( + hass: HomeAssistant, +) -> None: + """Test the remove item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="beer", uid="3", status=TodoItemStatus.COMPLETED), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + config_entry = await create_mock_platform(hass, [entity1]) + assert config_entry.state is ConfigEntryState.LOADED + + assert len(entity1.items) == 3 + + # Remove item + async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_REMOVE_ITEM) + response = await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_REMOVE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # only the first matching item has been removed + assert len(entity1.items) == 2 + assert entity1.items[0].uid == "2" + assert entity1.items[1].uid == "3" + + +async def test_remove_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the remove item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + await create_mock_platform(hass, [entity1]) + + # Try to remove item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_REMOVE_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Try to remove item that does not exist + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_REMOVE_ITEM, + {ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + )