Add due date and description to CalDAV To-do (#104656)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
Allen Porter 2023-11-29 10:35:36 -08:00 committed by GitHub
parent 1522118453
commit af2f8699b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 142 additions and 24 deletions

View File

@ -2,10 +2,10 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from datetime import date, datetime, timedelta
from functools import partial
import logging
from typing import cast
from typing import Any, cast
import caldav
from caldav.lib.error import DAVError, NotFoundError
@ -21,6 +21,7 @@ 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
@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | 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,
@ -78,9 +85,28 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
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:
if isinstance(due, datetime):
item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ")
else:
item_data["due"] = due.strftime("%Y%m%d")
if description := item.description:
item_data["description"] = description
return item_data
class WebDavTodoListEntity(TodoListEntity):
"""CalDAV To-do list entity."""
@ -89,6 +115,9 @@ class WebDavTodoListEntity(TodoListEntity):
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:
@ -116,13 +145,7 @@ class WebDavTodoListEntity(TodoListEntity):
"""Add an item to the To-do list."""
try:
await self.hass.async_add_executor_job(
partial(
self._calendar.save_todo,
summary=item.summary,
status=TODO_STATUS_MAP_INV.get(
item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION"
),
),
partial(self._calendar.save_todo, **_to_ics_fields(item)),
)
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@ -139,10 +162,7 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined]
if item.summary:
vtodo["summary"] = item.summary
if item.status:
vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION")
vtodo.update(**_to_ics_fields(item))
try:
await self.hass.async_add_executor_job(
partial(

View File

@ -17,7 +17,7 @@ from tests.typing import WebSocketGenerator
CALENDAR_NAME = "My Tasks"
ENTITY_NAME = "My tasks"
TEST_ENTITY = "todo.my_tasks"
SUPPORTED_FEATURES = 7
SUPPORTED_FEATURES = 119
TODO_NO_STATUS = """BEGIN:VCALENDAR
VERSION:2.0
@ -40,6 +40,12 @@ STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR"""
RESULT_ITEM = {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
}
TODO_COMPLETED = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
@ -69,6 +75,12 @@ def platforms() -> list[Platform]:
return [Platform.TODO]
@pytest.fixture(autouse=True)
def set_tz(hass: HomeAssistant) -> None:
"""Fixture to set timezone with fixed offset year round."""
hass.config.set_time_zone("America/Regina")
@pytest.fixture(name="todos")
def mock_todos() -> list[str]:
"""Fixture to return VTODO objects for the calendar."""
@ -178,10 +190,49 @@ async def test_supported_components(
assert (state is not None) == has_entity
@pytest.mark.parametrize(
("item_data", "expcted_save_args", "expected_item"),
[
(
{},
{"status": "NEEDS-ACTION", "summary": "Cheese"},
RESULT_ITEM,
),
(
{"due_date": "2023-11-18"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"},
{**RESULT_ITEM, "due": "2023-11-18"},
),
(
{"due_datetime": "2023-11-18T08:30:00-06:00"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"},
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"},
),
(
{"description": "Make sure to get Swiss"},
{
"status": "NEEDS-ACTION",
"summary": "Cheese",
"description": "Make sure to get Swiss",
},
{**RESULT_ITEM, "description": "Make sure to get Swiss"},
),
],
ids=[
"summary",
"due_date",
"due_datetime",
"description",
],
)
async def test_add_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
item_data: dict[str, Any],
expcted_save_args: dict[str, Any],
expected_item: dict[str, Any],
) -> None:
"""Test adding an item to the list."""
calendar.search.return_value = []
@ -197,16 +248,13 @@ async def test_add_item(
await hass.services.async_call(
TODO_DOMAIN,
"add_item",
{"item": "Cheese"},
{"item": "Cheese", **item_data},
target={"entity_id": TEST_ENTITY},
blocking=True,
)
assert calendar.save_todo.call_args
assert calendar.save_todo.call_args.kwargs == {
"status": "NEEDS-ACTION",
"summary": "Cheese",
}
assert calendar.save_todo.call_args.kwargs == expcted_save_args
# Verify state was updated
state = hass.states.get(TEST_ENTITY)
@ -235,20 +283,59 @@ async def test_add_item_failure(
@pytest.mark.parametrize(
("update_data", "expected_ics", "expected_state"),
("update_data", "expected_ics", "expected_state", "expected_item"),
[
(
{"rename": "Swiss Cheese"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"],
"1",
{**RESULT_ITEM, "summary": "Swiss Cheese"},
),
(
{"status": "needs_action"},
["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"],
"1",
RESULT_ITEM,
),
(
{"status": "completed"},
["SUMMARY:Cheese", "STATUS:COMPLETED"],
"0",
{**RESULT_ITEM, "status": "completed"},
),
({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"),
({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"),
(
{"rename": "Swiss Cheese", "status": "needs_action"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"],
"1",
{**RESULT_ITEM, "summary": "Swiss Cheese"},
),
(
{"due_date": "2023-11-18"},
["SUMMARY:Cheese", "DUE:20231118"],
"1",
{**RESULT_ITEM, "due": "2023-11-18"},
),
(
{"due_datetime": "2023-11-18T08:30:00-06:00"},
["SUMMARY:Cheese", "DUE:20231118T143000Z"],
"1",
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"},
),
(
{"description": "Make sure to get Swiss"},
["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"],
"1",
{**RESULT_ITEM, "description": "Make sure to get Swiss"},
),
],
ids=[
"rename",
"status_needs_action",
"status_completed",
"rename_status",
"due_date",
"due_datetime",
"description",
],
)
async def test_update_item(
@ -259,8 +346,9 @@ async def test_update_item(
update_data: dict[str, Any],
expected_ics: list[str],
expected_state: str,
expected_item: dict[str, Any],
) -> None:
"""Test creating a an item on the list."""
"""Test updating an item on the list."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
@ -295,6 +383,16 @@ async def test_update_item(
assert state
assert state.state == expected_state
result = await hass.services.async_call(
TODO_DOMAIN,
"get_items",
{},
target={"entity_id": TEST_ENTITY},
blocking=True,
return_response=True,
)
assert result == {TEST_ENTITY: {"items": [expected_item]}}
async def test_update_item_failure(
hass: HomeAssistant,
@ -506,7 +604,7 @@ async def test_subscribe(
calendar: Mock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test creating a an item on the list."""
"""Test subscription to item updates."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])