mirror of
https://github.com/home-assistant/core.git
synced 2025-08-09 23:55:07 +02:00
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:
@@ -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)
|
||||||
|
@@ -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,
|
||||||
|
@@ -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(
|
||||||
|
Reference in New Issue
Block a user