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
This commit is contained in:
Allen Porter
2023-11-29 22:01:57 -08:00
committed by GitHub
parent c72e4e8b5c
commit 64a6a6a778
3 changed files with 236 additions and 21 deletions

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
"""Unit tests for the Todoist todo platform.""" """Unit tests for the Todoist todo platform."""
from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from todoist_api_python.models import Due, Task
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
@@ -19,6 +21,12 @@ def platforms() -> list[Platform]:
return [Platform.TODO] 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( @pytest.mark.parametrize(
("tasks", "expected_state"), ("tasks", "expected_state"),
[ [
@@ -57,11 +65,91 @@ async def test_todo_item_state(
assert state.state == expected_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( async def test_add_todo_list_item(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: None, setup_integration: None,
api: AsyncMock, api: AsyncMock,
item_data: dict[str, Any],
tasks_after_update: list[Task],
add_kwargs: dict[str, Any],
expected_item: dict[str, Any],
) -> None: ) -> None:
"""Test for adding a To-do Item.""" """Test for adding a To-do Item."""
@@ -71,28 +159,35 @@ async def test_add_todo_list_item(
api.add_task = AsyncMock() api.add_task = AsyncMock()
# Fake API response when state is refreshed after create # Fake API response when state is refreshed after create
api.get_tasks.return_value = [ api.get_tasks.return_value = tasks_after_update
make_api_task(id="task-id-1", content="Soda", is_completed=False)
]
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"add_item", "add_item",
{"item": "Soda"}, {"item": "Soda", **item_data},
target={"entity_id": "todo.name"}, target={"entity_id": "todo.name"},
blocking=True, blocking=True,
) )
args = api.add_task.call_args args = api.add_task.call_args
assert args assert args
assert args.kwargs.get("content") == "Soda" assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs}
assert args.kwargs.get("project_id") == PROJECT_ID
# Verify state is refreshed # Verify state is refreshed
state = hass.states.get("todo.name") state = hass.states.get("todo.name")
assert state assert state
assert state.state == "1" 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( @pytest.mark.parametrize(
("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] ("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( @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, hass: HomeAssistant,
setup_integration: None, setup_integration: None,
api: AsyncMock, api: AsyncMock,
update_data: dict[str, Any],
tasks_after_update: list[Task],
update_kwargs: dict[str, Any],
expected_item: dict[str, Any],
) -> None: ) -> None:
"""Test for updating a To-do Item that changes the summary.""" """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() api.update_task = AsyncMock()
# Fake API response when state is refreshed after close # Fake API response when state is refreshed after close
api.get_tasks.return_value = [ api.get_tasks.return_value = tasks_after_update
make_api_task(id="task-id-1", content="Soda", is_completed=True)
]
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"update_item", "update_item",
{"item": "task-id-1", "rename": "Milk"}, {"item": "task-id-1", **update_data},
target={"entity_id": "todo.name"}, target={"entity_id": "todo.name"},
blocking=True, blocking=True,
) )
assert api.update_task.called assert api.update_task.called
args = api.update_task.call_args args = api.update_task.call_args
assert args assert args
assert args.kwargs.get("task_id") == "task-id-1" assert args.kwargs == update_kwargs
assert args.kwargs.get("content") == "Milk"
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( @pytest.mark.parametrize(