Add CalDAV To-do item support for Add, Update, and Delete (#103922)

* Add CalDAV To-do item support for Add, Update, and Delete

* Remove unnecessary cast

* Fix ruff error

* Fix ruff errors

* Remove exception from error message

* Remove unnecessary duplicate state update
This commit is contained in:
Allen Porter 2023-11-15 16:57:46 -08:00 committed by GitHub
parent 422b09f4ec
commit 613afe322f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 433 additions and 9 deletions

View File

@ -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

View File

@ -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,
)