Add read-only Caldav todo platform (#103415)

* Add Caldav todo enttiy for VTODO components

* Use new shared apis for todos

* Update homeassistant/components/caldav/todo.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update todo item conversion checks

* Iterate over results once

* Add 15 minute poll interval for caldav todo

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-11-07 00:11:52 -08:00 committed by GitHub
parent b6a3f628d1
commit 0a05a16fcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 257 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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