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:
parent
ee1007abdb
commit
0b8f48205a
6 changed files with 414 additions and 28 deletions
|
@ -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:
|
||||
|
|
111
homeassistant/components/todoist/todo.py
Normal file
111
homeassistant/components/todoist/todo.py
Normal 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()
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
256
tests/components/todoist/test_todo.py
Normal file
256
tests/components/todoist/test_todo.py
Normal 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"
|
Loading…
Add table
Reference in a new issue