diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index d62ff3eb5ce..eed06a3a005 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index b818e61dd2b..f9236049048 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -23,3 +23,10 @@ async def async_get_calendars( for calendar, supported_components in zip(calendars, components_results) if component in supported_components ] + + +def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: + """Return the value of the CalDav object attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index ee34a56e23b..380471284de 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util +from .api import get_attr_value + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -59,11 +61,11 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): continue event_list.append( CalendarEvent( - summary=self.get_attr_value(vevent, "summary") or "", + summary=get_attr_value(vevent, "summary") or "", start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), ) ) @@ -150,15 +152,15 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): # Populate the entity attributes with the event values (summary, offset) = extract_offset( - self.get_attr_value(vevent, "summary") or "", OFFSET + get_attr_value(vevent, "summary") or "", OFFSET ) self.offset = offset return CalendarEvent( summary=summary, start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), ) @staticmethod @@ -208,13 +210,6 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): return dt_util.as_local(obj) return obj - @staticmethod - def get_attr_value(obj, attribute): - """Return the value of the attribute if defined.""" - if hasattr(obj, attribute): - return getattr(obj, attribute).value - return None - @staticmethod def get_end_date(obj): """Return the end datetime as determined by dtend or duration.""" diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py new file mode 100644 index 00000000000..887f760399b --- /dev/null +++ b/homeassistant/components/caldav/todo.py @@ -0,0 +1,94 @@ +"""CalDAV todo platform.""" +from __future__ import annotations + +from datetime import timedelta +from functools import partial +import logging + +import caldav + +from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .api import async_get_calendars, get_attr_value +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=15) + +SUPPORTED_COMPONENT = "VTODO" +TODO_STATUS_MAP = { + "NEEDS-ACTION": TodoItemStatus.NEEDS_ACTION, + "IN-PROCESS": TodoItemStatus.NEEDS_ACTION, + "COMPLETED": TodoItemStatus.COMPLETED, + "CANCELLED": TodoItemStatus.COMPLETED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the CalDav todo platform for a config entry.""" + client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) + async_add_entities( + ( + WebDavTodoListEntity( + calendar, + entry.entry_id, + ) + for calendar in calendars + ), + True, + ) + + +def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: + """Convert a caldav Todo into a TodoItem.""" + if ( + not hasattr(resource.instance, "vtodo") + or not (todo := resource.instance.vtodo) + or (uid := get_attr_value(todo, "uid")) is None + or (summary := get_attr_value(todo, "summary")) is None + ): + return None + return TodoItem( + uid=uid, + summary=summary, + status=TODO_STATUS_MAP.get( + get_attr_value(todo, "status") or "", + TodoItemStatus.NEEDS_ACTION, + ), + ) + + +class WebDavTodoListEntity(TodoListEntity): + """CalDAV To-do list entity.""" + + _attr_has_entity_name = True + + def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: + """Initialize WebDavTodoListEntity.""" + self._calendar = calendar + self._attr_name = (calendar.name or "Unknown").capitalize() + self._attr_unique_id = f"{config_entry_id}-{calendar.id}" + + async def async_update(self) -> None: + """Update To-do list entity state.""" + results = await self.hass.async_add_executor_job( + partial( + self._calendar.search, + todo=True, + include_completed=True, + ) + ) + self._attr_todo_items = [ + todo_item + for resource in results + if (todo_item := _todo_item(resource)) is not None + ] diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 8a947747ab9..5a648949f0f 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -365,7 +365,7 @@ def _mock_calendar(name: str, supported_components: list[str] | None = None) -> calendar = Mock() events = [] for idx, event in enumerate(EVENTS): - events.append(Event(None, "%d.ics" % idx, event, calendar, str(idx))) + events.append(Event(None, f"{idx}.ics", event, calendar, str(idx))) if supported_components is None: supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py new file mode 100644 index 00000000000..16a95d418a8 --- /dev/null +++ b/tests/components/caldav/test_todo.py @@ -0,0 +1,146 @@ +"""The tests for the webdav todo component.""" +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, Mock + +from caldav.objects import Todo +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +CALENDAR_NAME = "My Tasks" +ENTITY_NAME = "My tasks" +TEST_ENTITY = "todo.my_tasks" + +TODO_NO_STATUS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:1 +DTSTAMP:20231125T000000Z +SUMMARY:Milk +END:VTODO +END:VCALENDAR""" + +TODO_NEEDS_ACTION = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + +TODO_COMPLETED = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:3 +DTSTAMP:20231125T000000Z +SUMMARY:Wine +STATUS:COMPLETED +END:VTODO +END:VCALENDAR""" + + +TODO_NO_SUMMARY = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:4 +DTSTAMP:20171126T000000Z +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set up config entry platforms.""" + return [Platform.TODO] + + +@pytest.fixture(name="todos") +def mock_todos() -> list[str]: + """Fixture to return VTODO objects for the calendar.""" + return [] + + +@pytest.fixture(name="supported_components") +def mock_supported_components() -> list[str]: + """Fixture to set supported components of the calendar.""" + 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.""" + 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.name = CALENDAR_NAME + calendar.get_supported_components = MagicMock(return_value=supported_components) + return [calendar] + + +@pytest.mark.parametrize( + ("todos", "expected_state"), + [ + ([], "0"), + ( + [TODO_NEEDS_ACTION], + "1", + ), + ( + [TODO_NO_STATUS], + "1", + ), + ([TODO_COMPLETED], "0"), + ([TODO_NO_STATUS, TODO_NEEDS_ACTION, TODO_COMPLETED], "2"), + ([TODO_NO_SUMMARY], "0"), + ], + ids=( + "empty", + "needs_action", + "no_status", + "completed", + "all", + "no_summary", + ), +) +async def test_todo_list_state( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + expected_state: str, +) -> None: + """Test a calendar entity from a config entry.""" + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == ENTITY_NAME + assert state.state == expected_state + assert dict(state.attributes) == { + "friendly_name": ENTITY_NAME, + } + + +@pytest.mark.parametrize( + ("supported_components", "has_entity"), + [([], False), (["VTODO"], True), (["VEVENT"], False), (["VEVENT", "VTODO"], True)], +) +async def test_supported_components( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + has_entity: bool, +) -> None: + """Test a calendar supported components matches VTODO.""" + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity