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 from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta from datetime import date, datetime, timedelta
from functools import partial from functools import partial
import logging import logging
from typing import cast from typing import Any, cast
import caldav import caldav
from caldav.lib.error import DAVError, NotFoundError from caldav.lib.error import DAVError, NotFoundError
@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .api import async_get_calendars, get_attr_value
from .const import DOMAIN 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 or (summary := get_attr_value(todo, "summary")) is None
): ):
return 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( return TodoItem(
uid=uid, uid=uid,
summary=summary, summary=summary,
@ -78,9 +85,28 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
get_attr_value(todo, "status") or "", get_attr_value(todo, "status") or "",
TodoItemStatus.NEEDS_ACTION, 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): class WebDavTodoListEntity(TodoListEntity):
"""CalDAV To-do list entity.""" """CalDAV To-do list entity."""
@ -89,6 +115,9 @@ class WebDavTodoListEntity(TodoListEntity):
TodoListEntityFeature.CREATE_TODO_ITEM TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_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: 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.""" """Add an item to the To-do list."""
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial( partial(self._calendar.save_todo, **_to_ics_fields(item)),
self._calendar.save_todo,
summary=item.summary,
status=TODO_STATUS_MAP_INV.get(
item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION"
),
),
) )
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err raise HomeAssistantError(f"CalDAV save error: {err}") from err
@ -139,10 +162,7 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined] vtodo = todo.icalendar_component # type: ignore[attr-defined]
if item.summary: vtodo.update(**_to_ics_fields(item))
vtodo["summary"] = item.summary
if item.status:
vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION")
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial( partial(

View File

@ -17,7 +17,7 @@ from tests.typing import WebSocketGenerator
CALENDAR_NAME = "My Tasks" CALENDAR_NAME = "My Tasks"
ENTITY_NAME = "My tasks" ENTITY_NAME = "My tasks"
TEST_ENTITY = "todo.my_tasks" TEST_ENTITY = "todo.my_tasks"
SUPPORTED_FEATURES = 7 SUPPORTED_FEATURES = 119
TODO_NO_STATUS = """BEGIN:VCALENDAR TODO_NO_STATUS = """BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
@ -40,6 +40,12 @@ STATUS:NEEDS-ACTION
END:VTODO END:VTODO
END:VCALENDAR""" END:VCALENDAR"""
RESULT_ITEM = {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
}
TODO_COMPLETED = """BEGIN:VCALENDAR TODO_COMPLETED = """BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN PRODID:-//E-Corp.//CalDAV Client//EN
@ -69,6 +75,12 @@ def platforms() -> list[Platform]:
return [Platform.TODO] 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") @pytest.fixture(name="todos")
def mock_todos() -> list[str]: def mock_todos() -> list[str]:
"""Fixture to return VTODO objects for the calendar.""" """Fixture to return VTODO objects for the calendar."""
@ -178,10 +190,49 @@ async def test_supported_components(
assert (state is not None) == has_entity 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( async def test_add_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock, calendar: Mock,
item_data: dict[str, Any],
expcted_save_args: dict[str, Any],
expected_item: dict[str, Any],
) -> None: ) -> None:
"""Test adding an item to the list.""" """Test adding an item to the list."""
calendar.search.return_value = [] calendar.search.return_value = []
@ -197,16 +248,13 @@ async def test_add_item(
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"add_item", "add_item",
{"item": "Cheese"}, {"item": "Cheese", **item_data},
target={"entity_id": TEST_ENTITY}, target={"entity_id": TEST_ENTITY},
blocking=True, blocking=True,
) )
assert calendar.save_todo.call_args assert calendar.save_todo.call_args
assert calendar.save_todo.call_args.kwargs == { assert calendar.save_todo.call_args.kwargs == expcted_save_args
"status": "NEEDS-ACTION",
"summary": "Cheese",
}
# Verify state was updated # Verify state was updated
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
@ -235,20 +283,59 @@ async def test_add_item_failure(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("update_data", "expected_ics", "expected_state"), ("update_data", "expected_ics", "expected_state", "expected_item"),
[ [
( (
{"rename": "Swiss Cheese"}, {"rename": "Swiss Cheese"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"],
"1", "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"}, {"rename": "Swiss Cheese", "status": "needs_action"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"],
"1", "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( async def test_update_item(
@ -259,8 +346,9 @@ async def test_update_item(
update_data: dict[str, Any], update_data: dict[str, Any],
expected_ics: list[str], expected_ics: list[str],
expected_state: str, expected_state: str,
expected_item: dict[str, Any],
) -> None: ) -> 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") item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item]) calendar.search = MagicMock(return_value=[item])
@ -295,6 +383,16 @@ async def test_update_item(
assert state assert state
assert state.state == expected_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( async def test_update_item_failure(
hass: HomeAssistant, hass: HomeAssistant,
@ -506,7 +604,7 @@ async def test_subscribe(
calendar: Mock, calendar: Mock,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test creating a an item on the list.""" """Test subscription to item updates."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item]) calendar.search = MagicMock(return_value=[item])