Add Todoist To-do list support (#102633)

* Add todoist todo platform

* Fix comment in todoist todo platform

* Revert CalData cleanup and logging

* Fix bug in fetching tasks per project

* Add test coverage for creating active tasks

* Fix update behavior on startup
This commit is contained in:
Allen Porter 2023-10-24 13:47:26 -07:00 committed by GitHub
parent ee1007abdb
commit 0b8f48205a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 414 additions and 28 deletions

View File

@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = datetime.timedelta(minutes=1) SCAN_INTERVAL = datetime.timedelta(minutes=1)
PLATFORMS: list[Platform] = [Platform.CALENDAR] PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@ -0,0 +1,111 @@
"""A todo platform for Todoist."""
import asyncio
from typing import cast
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
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 .const import DOMAIN
from .coordinator import TodoistCoordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Todoist todo platform config entry."""
coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id]
projects = await coordinator.async_get_projects()
async_add_entities(
TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name)
for project in projects
)
class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity):
"""A Todoist TodoListEntity."""
_attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
)
def __init__(
self,
coordinator: TodoistCoordinator,
config_entry_id: str,
project_id: str,
project_name: str,
) -> None:
"""Initialize TodoistTodoListEntity."""
super().__init__(coordinator=coordinator)
self._project_id = project_id
self._attr_unique_id = f"{config_entry_id}-{project_id}"
self._attr_name = project_name
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data is None:
self._attr_todo_items = None
else:
items = []
for task in self.coordinator.data:
if task.project_id != self._project_id:
continue
if task.is_completed:
status = TodoItemStatus.COMPLETED
else:
status = TodoItemStatus.NEEDS_ACTION
items.append(
TodoItem(
summary=task.content,
uid=task.id,
status=status,
)
)
self._attr_todo_items = items
super()._handle_coordinator_update()
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a To-do item."""
if item.status != TodoItemStatus.NEEDS_ACTION:
raise ValueError("Only active tasks may be created.")
await self.coordinator.api.add_task(
content=item.summary or "",
project_id=self._project_id,
)
await self.coordinator.async_refresh()
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 item.status is not None:
if item.status == TodoItemStatus.COMPLETED:
await self.coordinator.api.close_task(task_id=uid)
else:
await self.coordinator.api.reopen_task(task_id=uid)
await self.coordinator.async_refresh()
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete a To-do item."""
await asyncio.gather(
*[self.coordinator.api.delete_task(task_id=uid) for uid in uids]
)
await self.coordinator.async_refresh()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass update state from existing coordinator data."""
await super().async_added_to_hass()
self._handle_coordinator_update()

View File

@ -9,15 +9,17 @@ from requests.models import Response
from todoist_api_python.models import Collaborator, Due, Label, Project, Task from todoist_api_python.models import Collaborator, Due, Label, Project, Task
from homeassistant.components.todoist import DOMAIN from homeassistant.components.todoist import DOMAIN
from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
PROJECT_ID = "project-id-1"
SUMMARY = "A task" SUMMARY = "A task"
TOKEN = "some-token" TOKEN = "some-token"
TODAY = dt_util.now().strftime("%Y-%m-%d")
@pytest.fixture @pytest.fixture
@ -37,38 +39,49 @@ def mock_due() -> Due:
) )
@pytest.fixture(name="task") def make_api_task(
def mock_task(due: Due) -> Task: id: str | None = None,
content: str | None = None,
is_completed: bool = False,
due: Due | None = None,
project_id: str | None = None,
) -> Task:
"""Mock a todoist Task instance.""" """Mock a todoist Task instance."""
return Task( return Task(
assignee_id="1", assignee_id="1",
assigner_id="1", assigner_id="1",
comment_count=0, comment_count=0,
is_completed=False, is_completed=is_completed,
content=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="A task",
due=due, due=due or Due(is_recurring=False, date=TODAY, string="today"),
id="1", id=id or "1",
labels=["Label1"], labels=["Label1"],
order=1, order=1,
parent_id=None, parent_id=None,
priority=1, priority=1,
project_id="12345", project_id=project_id or PROJECT_ID,
section_id=None, section_id=None,
url="https://todoist.com", url="https://todoist.com",
sync_id=None, sync_id=None,
) )
@pytest.fixture(name="tasks")
def mock_tasks(due: Due) -> list[Task]:
"""Mock a todoist Task instance."""
return [make_api_task(due=due)]
@pytest.fixture(name="api") @pytest.fixture(name="api")
def mock_api(task) -> AsyncMock: def mock_api(tasks: list[Task]) -> AsyncMock:
"""Mock the api state.""" """Mock the api state."""
api = AsyncMock() api = AsyncMock()
api.get_projects.return_value = [ api.get_projects.return_value = [
Project( Project(
id="12345", id=PROJECT_ID,
color="blue", color="blue",
comment_count=0, comment_count=0,
is_favorite=False, is_favorite=False,
@ -88,7 +101,7 @@ def mock_api(task) -> AsyncMock:
api.get_collaborators.return_value = [ api.get_collaborators.return_value = [
Collaborator(email="user@gmail.com", id="1", name="user") Collaborator(email="user@gmail.com", id="1", name="user")
] ]
api.get_tasks.return_value = [task] api.get_tasks.return_value = tasks
return api return api
@ -121,15 +134,25 @@ def mock_todoist_domain() -> str:
return DOMAIN return DOMAIN
@pytest.fixture(autouse=True)
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return []
@pytest.fixture(name="setup_integration") @pytest.fixture(name="setup_integration")
async def mock_setup_integration( async def mock_setup_integration(
hass: HomeAssistant, hass: HomeAssistant,
platforms: list[Platform],
api: AsyncMock, api: AsyncMock,
todoist_config_entry: MockConfigEntry | None, todoist_config_entry: MockConfigEntry | None,
) -> None: ) -> None:
"""Mock setup of the todoist integration.""" """Mock setup of the todoist integration."""
if todoist_config_entry is not None: if todoist_config_entry is not None:
todoist_config_entry.add_to_hass(hass) todoist_config_entry.add_to_hass(hass)
with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): with patch(
"homeassistant.components.todoist.TodoistAPIAsync", return_value=api
), patch("homeassistant.components.todoist.PLATFORMS", platforms):
assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield yield

View File

@ -18,13 +18,13 @@ from homeassistant.components.todoist.const import (
PROJECT_NAME, PROJECT_NAME,
SERVICE_NEW_TASK, SERVICE_NEW_TASK,
) )
from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .conftest import SUMMARY from .conftest import PROJECT_ID, SUMMARY
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -34,6 +34,12 @@ TZ_NAME = "America/Regina"
TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME)
@pytest.fixture(autouse=True)
def platforms() -> list[Platform]:
"""Override platforms."""
return [Platform.CALENDAR]
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def set_time_zone(hass: HomeAssistant): def set_time_zone(hass: HomeAssistant):
"""Set the time zone for the tests.""" """Set the time zone for the tests."""
@ -97,7 +103,7 @@ async def test_calendar_entity_unique_id(
) -> None: ) -> None:
"""Test unique id is set to project id.""" """Test unique id is set to project id."""
entity = entity_registry.async_get("calendar.name") entity = entity_registry.async_get("calendar.name")
assert entity.unique_id == "12345" assert entity.unique_id == PROJECT_ID
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -256,7 +262,7 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) ->
await hass.async_block_till_done() await hass.async_block_till_done()
api.add_task.assert_called_with( api.add_task.assert_called_with(
"task", project_id="12345", labels=["Label1"], assignee_id="1" "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1"
) )

View File

@ -1,7 +1,6 @@
"""Unit tests for the Todoist integration.""" """Unit tests for the Todoist integration."""
from collections.abc import Generator
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
import pytest import pytest
@ -12,15 +11,6 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_platforms() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.todoist.PLATFORMS", return_value=[]
) as mock_setup_entry:
yield mock_setup_entry
async def test_load_unload( async def test_load_unload(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: None, setup_integration: None,

View File

@ -0,0 +1,256 @@
"""Unit tests for the Todoist todo platform."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from .conftest import PROJECT_ID, make_api_task
@pytest.fixture(autouse=True)
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.TODO]
@pytest.mark.parametrize(
("tasks", "expected_state"),
[
([], "0"),
([make_api_task(id="12345", content="Soda", is_completed=False)], "1"),
([make_api_task(id="12345", content="Soda", is_completed=True)], "0"),
(
[
make_api_task(id="12345", content="Milk", is_completed=False),
make_api_task(id="54321", content="Soda", is_completed=False),
],
"2",
),
(
[
make_api_task(
id="12345",
content="Soda",
is_completed=False,
project_id="other-project-id",
)
],
"0",
),
],
)
async def test_todo_item_state(
hass: HomeAssistant,
setup_integration: None,
expected_state: str,
) -> None:
"""Test for a To-do List entity state."""
state = hass.states.get("todo.name")
assert state
assert state.state == expected_state
@pytest.mark.parametrize(("tasks"), [[]])
async def test_create_todo_list_item(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
) -> None:
"""Test for creating a To-do Item."""
state = hass.states.get("todo.name")
assert state
assert state.state == "0"
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)
]
await hass.services.async_call(
TODO_DOMAIN,
"create_item",
{"summary": "Soda"},
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
# Verify state is refreshed
state = hass.states.get("todo.name")
assert state
assert state.state == "1"
@pytest.mark.parametrize(("tasks"), [[]])
async def test_create_completed_item_unsupported(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
) -> None:
"""Test for creating a To-do Item that is already completed."""
state = hass.states.get("todo.name")
assert state
assert state.state == "0"
api.add_task = AsyncMock()
with pytest.raises(ValueError, match="Only active tasks"):
await hass.services.async_call(
TODO_DOMAIN,
"create_item",
{"summary": "Soda", "status": "completed"},
target={"entity_id": "todo.name"},
blocking=True,
)
@pytest.mark.parametrize(
("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]]
)
async def test_update_todo_item_status(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
) -> None:
"""Test for updating a To-do Item that changes the status."""
state = hass.states.get("todo.name")
assert state
assert state.state == "1"
api.close_task = AsyncMock()
api.reopen_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)
]
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"uid": "task-id-1", "status": "completed"},
target={"entity_id": "todo.name"},
blocking=True,
)
assert api.close_task.called
args = api.close_task.call_args
assert args
assert args.kwargs.get("task_id") == "task-id-1"
assert not api.reopen_task.called
# Verify state is refreshed
state = hass.states.get("todo.name")
assert state
assert state.state == "0"
# Fake API response when state is refreshed after reopen
api.get_tasks.return_value = [
make_api_task(id="task-id-1", content="Soda", is_completed=False)
]
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"uid": "task-id-1", "status": "needs_action"},
target={"entity_id": "todo.name"},
blocking=True,
)
assert api.reopen_task.called
args = api.reopen_task.call_args
assert args
assert args.kwargs.get("task_id") == "task-id-1"
# Verify state is refreshed
state = hass.states.get("todo.name")
assert state
assert state.state == "1"
@pytest.mark.parametrize(
("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]]
)
async def test_update_todo_item_summary(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
) -> None:
"""Test for updating a To-do Item that changes the summary."""
state = hass.states.get("todo.name")
assert state
assert state.state == "1"
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)
]
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"uid": "task-id-1", "summary": "Milk"},
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"
@pytest.mark.parametrize(
("tasks"),
[
[
make_api_task(id="task-id-1", content="Soda", is_completed=False),
make_api_task(id="task-id-2", content="Milk", is_completed=False),
]
],
)
async def test_delete_todo_item(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
) -> None:
"""Test for deleting a To-do Item."""
state = hass.states.get("todo.name")
assert state
assert state.state == "2"
api.delete_task = AsyncMock()
# Fake API response when state is refreshed after close
api.get_tasks.return_value = []
await hass.services.async_call(
TODO_DOMAIN,
"delete_item",
{"uid": ["task-id-1", "task-id-2"]},
target={"entity_id": "todo.name"},
blocking=True,
)
assert api.delete_task.call_count == 2
args = api.delete_task.call_args_list
assert args[0].kwargs.get("task_id") == "task-id-1"
assert args[1].kwargs.get("task_id") == "task-id-2"
await async_update_entity(hass, "todo.name")
state = hass.states.get("todo.name")
assert state
assert state.state == "0"