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

View file

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