diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 887f760399b..eddfe410100 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -1,15 +1,25 @@ """CalDAV todo platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from functools import partial import logging +from typing import cast import caldav +from caldav.lib.error import DAVError, NotFoundError +import requests -from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import async_get_calendars, get_attr_value @@ -26,6 +36,10 @@ TODO_STATUS_MAP = { "COMPLETED": TodoItemStatus.COMPLETED, "CANCELLED": TodoItemStatus.COMPLETED, } +TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = { + TodoItemStatus.NEEDS_ACTION: "NEEDS-ACTION", + TodoItemStatus.COMPLETED: "COMPLETED", +} async def async_setup_entry( @@ -71,6 +85,11 @@ class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: """Initialize WebDavTodoListEntity.""" @@ -92,3 +111,66 @@ class WebDavTodoListEntity(TodoListEntity): for resource in results if (todo_item := _todo_item(resource)) is not None ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + try: + await self.hass.async_add_executor_job( + partial( + self._calendar.save_todo, + summary=item.summary, + status=TODO_STATUS_MAP_INV.get( + item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION" + ), + ), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + try: + todo = await self.hass.async_add_executor_job( + self._calendar.todo_by_uid, uid + ) + except NotFoundError as err: + raise HomeAssistantError(f"Could not find To-do item {uid}") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + vtodo = todo.icalendar_component # type: ignore[attr-defined] + if item.summary: + vtodo["summary"] = item.summary + if item.status: + vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") + try: + await self.hass.async_add_executor_job( + partial( + todo.save, + no_create=True, + obj_type="todo", + ), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete To-do items.""" + tasks = ( + self.hass.async_add_executor_job(self._calendar.todo_by_uid, uid) + for uid in uids + ) + + try: + items = await asyncio.gather(*tasks) + except NotFoundError as err: + raise HomeAssistantError("Could not find To-do item") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + + # Run serially as some CalDAV servers do not support concurrent modifications + for item in items: + try: + await self.hass.async_add_executor_job(item.delete) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV delete error: {err}") from err diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 352b60d5ed3..31901515e5a 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,17 +1,22 @@ """The tests for the webdav todo component.""" +from typing import Any from unittest.mock import MagicMock, Mock +from caldav.lib.error import DAVError, NotFoundError from caldav.objects import Todo import pytest +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" +SUPPORTED_FEATURES = 7 TODO_NO_STATUS = """BEGIN:VCALENDAR VERSION:2.0 @@ -75,17 +80,32 @@ def mock_supported_components() -> list[str]: return ["VTODO"] -@pytest.fixture(name="calendars") -def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mock]: - """Fixture to create calendars for the test.""" +@pytest.fixture(name="calendar") +def mock_calendar(supported_components: list[str]) -> Mock: + """Fixture to create the primary calendar for the test.""" calendar = Mock() - items = [ - Todo(None, f"{idx}.ics", item, calendar, str(idx)) - for idx, item in enumerate(todos) - ] - calendar.search = MagicMock(return_value=items) + calendar.search = MagicMock(return_value=[]) calendar.name = CALENDAR_NAME calendar.get_supported_components = MagicMock(return_value=supported_components) + return calendar + + +def create_todo(calendar: Mock, idx: str, ics: str) -> Todo: + """Create a caldav Todo object.""" + return Todo(client=None, url=f"{idx}.ics", data=ics, parent=calendar, id=idx) + + +@pytest.fixture(autouse=True) +def mock_search_items(calendar: Mock, todos: list[str]) -> None: + """Fixture to add search results to the test calendar.""" + calendar.search.return_value = [ + create_todo(calendar, str(idx), item) for idx, item in enumerate(todos) + ] + + +@pytest.fixture(name="calendars") +def mock_calendars(calendar: Mock) -> list[Mock]: + """Fixture to create calendars for the test.""" return [calendar] @@ -137,6 +157,7 @@ async def test_todo_list_state( assert state.state == expected_state assert dict(state.attributes) == { "friendly_name": ENTITY_NAME, + "supported_features": SUPPORTED_FEATURES, } @@ -154,3 +175,324 @@ async def test_supported_components( state = hass.states.get(TEST_ENTITY) assert (state is not None) == has_entity + + +async def test_add_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, +) -> None: + """Test adding an item to the list.""" + calendar.search.return_value = [] + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Simulat return value for the state update after the service call + calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)] + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert calendar.save_todo.call_args + assert calendar.save_todo.call_args.kwargs == { + "status": "NEEDS-ACTION", + "summary": "Cheese", + } + + # Verify state was updated + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_add_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, +) -> None: + """Test failure when adding an item to the list.""" + await config_entry.async_setup(hass) + + calendar.save_todo.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("update_data", "expected_ics", "expected_state"), + [ + ( + {"rename": "Swiss Cheese"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + ), + ({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"), + ({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"), + ( + {"rename": "Swiss Cheese", "status": "needs_action"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + ), + ], +) +async def test_update_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + update_data: dict[str, Any], + expected_ics: list[str], + expected_state: str, +) -> None: + """Test creating a an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + calendar.todo_by_uid = MagicMock(return_value=item) + + dav_client.put.return_value.status = 204 + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + **update_data, + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert dav_client.put.call_args + ics = dav_client.put.call_args.args[1] + for expected in expected_ics: + assert expected in ics + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state + + +async def test_update_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test failure when updating an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("side_effect", "match"), + [(DAVError, "CalDAV lookup error"), (NotFoundError, "Could not find")], +) +async def test_update_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure when looking up an item to update.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("uids_to_delete", "expect_item1_delete_called", "expect_item2_delete_called"), + [ + ([], False, False), + (["Cheese"], True, False), + (["Wine"], False, True), + (["Wine", "Cheese"], True, True), + ], + ids=("none", "item1-only", "item2-only", "both-items"), +) +async def test_remove_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + uids_to_delete: list[str], + expect_item1_delete_called: bool, + expect_item2_delete_called: bool, +) -> None: + """Test removing an item on the list.""" + + item1 = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item2 = Todo(dav_client, None, TODO_COMPLETED, calendar, "3") + calendar.search = MagicMock(return_value=[item1, item2]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + def lookup(uid: str) -> Mock: + assert uid == "2" or uid == "3" + if uid == "2": + return item1 + return item2 + + calendar.todo_by_uid = Mock(side_effect=lookup) + item1.delete = Mock() + item2.delete = Mock() + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": uids_to_delete}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert item1.delete.called == expect_item1_delete_called + assert item2.delete.called == expect_item2_delete_called + + +@pytest.mark.parametrize( + ("todos", "side_effect", "match"), + [ + ([TODO_NEEDS_ACTION], DAVError, "CalDAV lookup error"), + ([TODO_NEEDS_ACTION], NotFoundError, "Could not find"), + ], +) +async def test_remove_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure while removing an item from the list.""" + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid = Mock(side_effect=lookup) + dav_client.delete.return_value.status = 500 + + with pytest.raises(HomeAssistantError, match="CalDAV delete error"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid.side_effect = NotFoundError() + + with pytest.raises(HomeAssistantError, match="Could not find"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + )