mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 06:47:09 +00:00
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:
parent
b6a3f628d1
commit
0a05a16fcb
@ -22,7 +22,7 @@ from .const import DOMAIN
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
@ -23,3 +23,10 @@ async def async_get_calendars(
|
|||||||
for calendar, supported_components in zip(calendars, components_results)
|
for calendar, supported_components in zip(calendars, components_results)
|
||||||
if component in supported_components
|
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
|
||||||
|
@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from .api import get_attr_value
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
|
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
|
||||||
@ -59,11 +61,11 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
|||||||
continue
|
continue
|
||||||
event_list.append(
|
event_list.append(
|
||||||
CalendarEvent(
|
CalendarEvent(
|
||||||
summary=self.get_attr_value(vevent, "summary") or "",
|
summary=get_attr_value(vevent, "summary") or "",
|
||||||
start=self.to_local(vevent.dtstart.value),
|
start=self.to_local(vevent.dtstart.value),
|
||||||
end=self.to_local(self.get_end_date(vevent)),
|
end=self.to_local(self.get_end_date(vevent)),
|
||||||
location=self.get_attr_value(vevent, "location"),
|
location=get_attr_value(vevent, "location"),
|
||||||
description=self.get_attr_value(vevent, "description"),
|
description=get_attr_value(vevent, "description"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -150,15 +152,15 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
|||||||
|
|
||||||
# Populate the entity attributes with the event values
|
# Populate the entity attributes with the event values
|
||||||
(summary, offset) = extract_offset(
|
(summary, offset) = extract_offset(
|
||||||
self.get_attr_value(vevent, "summary") or "", OFFSET
|
get_attr_value(vevent, "summary") or "", OFFSET
|
||||||
)
|
)
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
return CalendarEvent(
|
return CalendarEvent(
|
||||||
summary=summary,
|
summary=summary,
|
||||||
start=self.to_local(vevent.dtstart.value),
|
start=self.to_local(vevent.dtstart.value),
|
||||||
end=self.to_local(self.get_end_date(vevent)),
|
end=self.to_local(self.get_end_date(vevent)),
|
||||||
location=self.get_attr_value(vevent, "location"),
|
location=get_attr_value(vevent, "location"),
|
||||||
description=self.get_attr_value(vevent, "description"),
|
description=get_attr_value(vevent, "description"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -208,13 +210,6 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
|||||||
return dt_util.as_local(obj)
|
return dt_util.as_local(obj)
|
||||||
return 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
|
@staticmethod
|
||||||
def get_end_date(obj):
|
def get_end_date(obj):
|
||||||
"""Return the end datetime as determined by dtend or duration."""
|
"""Return the end datetime as determined by dtend or duration."""
|
||||||
|
94
homeassistant/components/caldav/todo.py
Normal file
94
homeassistant/components/caldav/todo.py
Normal 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
|
||||||
|
]
|
@ -365,7 +365,7 @@ def _mock_calendar(name: str, supported_components: list[str] | None = None) ->
|
|||||||
calendar = Mock()
|
calendar = Mock()
|
||||||
events = []
|
events = []
|
||||||
for idx, event in enumerate(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:
|
if supported_components is None:
|
||||||
supported_components = ["VEVENT"]
|
supported_components = ["VEVENT"]
|
||||||
calendar.search = MagicMock(return_value=events)
|
calendar.search = MagicMock(return_value=events)
|
||||||
|
146
tests/components/caldav/test_todo.py
Normal file
146
tests/components/caldav/test_todo.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user