mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
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:
parent
1522118453
commit
af2f8699b7
@ -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(
|
||||
|
@ -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])
|
||||
|
Loading…
x
Reference in New Issue
Block a user