mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +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__)
|
||||
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CALENDAR]
|
||||
PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO]
|
||||
|
||||
|
||||
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)
|
||||
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.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."""
|
||||
|
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()
|
||||
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)
|
||||
|
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