Add due date and description fields to Todoist To-do entity (#104655)

* Add Todoist Due date and description fields

* Update entity features with new names

* Make items into walrus

* Update due_datetime field

* Add additional tests for adding new fields to items

* Fix call args in todoist test
pull/105135/head
Allen Porter 2023-11-29 22:01:57 -08:00 committed by Franck Nijhof
parent 655b067277
commit db6b804298
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
3 changed files with 236 additions and 21 deletions

View File

@ -1,7 +1,8 @@
"""A todo platform for Todoist."""
import asyncio
from typing import cast
import datetime
from typing import Any, cast
from homeassistant.components.todo import (
TodoItem,
@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TodoistCoordinator
@ -30,6 +32,24 @@ async def async_setup_entry(
)
def _task_api_data(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["content"] = summary
if due := item.due:
if isinstance(due, datetime.datetime):
item_data["due"] = {
"date": due.date().isoformat(),
"datetime": due.isoformat(),
}
else:
item_data["due"] = {"date": due.isoformat()}
if description := item.description:
item_data["description"] = description
return item_data
class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity):
"""A Todoist TodoListEntity."""
@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
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__(
@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
status = TodoItemStatus.COMPLETED
else:
status = TodoItemStatus.NEEDS_ACTION
due: datetime.date | datetime.datetime | None = None
if task_due := task.due:
if task_due.datetime:
due = dt_util.as_local(
datetime.datetime.fromisoformat(task_due.datetime)
)
elif task_due.date:
due = datetime.date.fromisoformat(task_due.date)
items.append(
TodoItem(
summary=task.content,
uid=task.id,
status=status,
due=due,
description=task.description or None, # Don't use empty string
)
)
self._attr_todo_items = items
@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
if item.status != TodoItemStatus.NEEDS_ACTION:
raise ValueError("Only active tasks may be created.")
await self.coordinator.api.add_task(
content=item.summary or "",
**_task_api_data(item),
project_id=self._project_id,
)
await self.coordinator.async_refresh()
@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a To-do item."""
uid: str = cast(str, item.uid)
if item.summary:
await self.coordinator.api.update_task(task_id=uid, content=item.summary)
if update_data := _task_api_data(item):
await self.coordinator.api.update_task(task_id=uid, **update_data)
if item.status is not None:
if item.status == TodoItemStatus.COMPLETED:
await self.coordinator.api.close_task(task_id=uid)

View File

@ -45,6 +45,7 @@ def make_api_task(
is_completed: bool = False,
due: Due | None = None,
project_id: str | None = None,
description: str | None = None,
) -> Task:
"""Mock a todoist Task instance."""
return Task(
@ -55,8 +56,8 @@ def make_api_task(
content=content or SUMMARY,
created_at="2021-10-01T00:00:00",
creator_id="1",
description="A task",
due=due or Due(is_recurring=False, date=TODAY, string="today"),
description=description,
due=due,
id=id or "1",
labels=["Label1"],
order=1,

View File

@ -1,7 +1,9 @@
"""Unit tests for the Todoist todo platform."""
from typing import Any
from unittest.mock import AsyncMock
import pytest
from todoist_api_python.models import Due, Task
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import Platform
@ -19,6 +21,12 @@ def platforms() -> list[Platform]:
return [Platform.TODO]
@pytest.fixture(autouse=True)
def set_time_zone(hass: HomeAssistant) -> None:
"""Set the time zone for the tests that keesp UTC-6 all year round."""
hass.config.set_time_zone("America/Regina")
@pytest.mark.parametrize(
("tasks", "expected_state"),
[
@ -57,11 +65,91 @@ async def test_todo_item_state(
assert state.state == expected_state
@pytest.mark.parametrize(("tasks"), [[]])
@pytest.mark.parametrize(
("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"),
[
(
[],
{},
[make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"content": "Soda"},
{"uid": "task-id-1", "summary": "Soda", "status": "needs_action"},
),
(
[],
{"due_date": "2023-11-18"},
[
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
due=Due(is_recurring=False, date="2023-11-18", string="today"),
)
],
{"due": {"date": "2023-11-18"}},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"due": "2023-11-18",
},
),
(
[],
{"due_datetime": "2023-11-18T06:30:00"},
[
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
due=Due(
date="2023-11-18",
is_recurring=False,
datetime="2023-11-18T12:30:00.000000Z",
string="today",
),
)
],
{
"due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"},
},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"due": "2023-11-18T06:30:00-06:00",
},
),
(
[],
{"description": "6-pack"},
[
make_api_task(
id="task-id-1",
content="Soda",
description="6-pack",
is_completed=False,
)
],
{"description": "6-pack"},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"description": "6-pack",
},
),
],
ids=["summary", "due_date", "due_datetime", "description"],
)
async def test_add_todo_list_item(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
item_data: dict[str, Any],
tasks_after_update: list[Task],
add_kwargs: dict[str, Any],
expected_item: dict[str, Any],
) -> None:
"""Test for adding a To-do Item."""
@ -71,28 +159,35 @@ async def test_add_todo_list_item(
api.add_task = AsyncMock()
# Fake API response when state is refreshed after create
api.get_tasks.return_value = [
make_api_task(id="task-id-1", content="Soda", is_completed=False)
]
api.get_tasks.return_value = tasks_after_update
await hass.services.async_call(
TODO_DOMAIN,
"add_item",
{"item": "Soda"},
{"item": "Soda", **item_data},
target={"entity_id": "todo.name"},
blocking=True,
)
args = api.add_task.call_args
assert args
assert args.kwargs.get("content") == "Soda"
assert args.kwargs.get("project_id") == PROJECT_ID
assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs}
# Verify state is refreshed
state = hass.states.get("todo.name")
assert state
assert state.state == "1"
result = await hass.services.async_call(
TODO_DOMAIN,
"get_items",
{},
target={"entity_id": "todo.name"},
blocking=True,
return_response=True,
)
assert result == {"todo.name": {"items": [expected_item]}}
@pytest.mark.parametrize(
("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]]
@ -158,12 +253,91 @@ async def test_update_todo_item_status(
@pytest.mark.parametrize(
("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]]
("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"),
[
(
[make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"rename": "Milk"},
[make_api_task(id="task-id-1", content="Milk", is_completed=False)],
{"task_id": "task-id-1", "content": "Milk"},
{"uid": "task-id-1", "summary": "Milk", "status": "needs_action"},
),
(
[make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"due_date": "2023-11-18"},
[
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
due=Due(is_recurring=False, date="2023-11-18", string="today"),
)
],
{"task_id": "task-id-1", "due": {"date": "2023-11-18"}},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"due": "2023-11-18",
},
),
(
[make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"due_datetime": "2023-11-18T06:30:00"},
[
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
due=Due(
date="2023-11-18",
is_recurring=False,
datetime="2023-11-18T12:30:00.000000Z",
string="today",
),
)
],
{
"task_id": "task-id-1",
"due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"},
},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"due": "2023-11-18T06:30:00-06:00",
},
),
(
[make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"description": "6-pack"},
[
make_api_task(
id="task-id-1",
content="Soda",
description="6-pack",
is_completed=False,
)
],
{"task_id": "task-id-1", "description": "6-pack"},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
"description": "6-pack",
},
),
],
ids=["rename", "due_date", "due_datetime", "description"],
)
async def test_update_todo_item_summary(
async def test_update_todo_items(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
update_data: dict[str, Any],
tasks_after_update: list[Task],
update_kwargs: dict[str, Any],
expected_item: dict[str, Any],
) -> None:
"""Test for updating a To-do Item that changes the summary."""
@ -174,22 +348,29 @@ async def test_update_todo_item_summary(
api.update_task = AsyncMock()
# Fake API response when state is refreshed after close
api.get_tasks.return_value = [
make_api_task(id="task-id-1", content="Soda", is_completed=True)
]
api.get_tasks.return_value = tasks_after_update
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": "task-id-1", "rename": "Milk"},
{"item": "task-id-1", **update_data},
target={"entity_id": "todo.name"},
blocking=True,
)
assert api.update_task.called
args = api.update_task.call_args
assert args
assert args.kwargs.get("task_id") == "task-id-1"
assert args.kwargs.get("content") == "Milk"
assert args.kwargs == update_kwargs
result = await hass.services.async_call(
TODO_DOMAIN,
"get_items",
{},
target={"entity_id": "todo.name"},
blocking=True,
return_response=True,
)
assert result == {"todo.name": {"items": [expected_item]}}
@pytest.mark.parametrize(