mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 11:47:06 +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
|
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(
|
||||||
|
@ -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])
|
||||||
|
Loading…
x
Reference in New Issue
Block a user