hass-core/homeassistant/components/caldav/todo.py
Florian B 063ac53f01
Fix adding/updating todo items with due date in CalDAV integration (#105435)
* refactor: return date/datetime for due date

* fix: explicitly set due date on vTODO component

Using `set_due` automatically handles converting the Python-native
date/datetime values to the correct representation required by RFC5545.

* fix: fix tests with changed due date handling

* fix: item.due may not be a str

* refactor: keep local timezone of due datetime

* refactor: reorder import statement

To make ruff happy.

* fix: fix false-positive mypy error
2023-12-10 08:23:05 -08:00

196 lines
6.7 KiB
Python

"""CalDAV todo platform."""
from __future__ import annotations
import asyncio
from datetime import date, datetime, timedelta
from functools import partial
import logging
from typing import Any, cast
import caldav
from caldav.lib.error import DAVError, NotFoundError
import requests
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 homeassistant.util import dt as dt_util
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,
}
TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = {
TodoItemStatus.NEEDS_ACTION: "NEEDS-ACTION",
TodoItemStatus.COMPLETED: "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
due: date | datetime | None = None
if due_value := get_attr_value(todo, "due"):
if isinstance(due_value, datetime):
due = dt_util.as_local(due_value)
elif isinstance(due_value, date):
due = due_value
return TodoItem(
uid=uid,
summary=summary,
status=TODO_STATUS_MAP.get(
get_attr_value(todo, "status") or "",
TodoItemStatus.NEEDS_ACTION,
),
due=due,
description=get_attr_value(todo, "description"),
)
def _to_ics_fields(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["summary"] = summary
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
item_data["due"] = due
if description := item.description:
item_data["description"] = description
return item_data
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
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
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
]
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, **_to_ics_fields(item)),
)
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]
updated_fields = _to_ics_fields(item)
if "due" in updated_fields:
todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined]
vtodo.update(**updated_fields)
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