Add DataUpdateCoordinator to the Todoist integration (#89836)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Aaron Godfrey 2023-03-28 09:57:24 -07:00 committed by GitHub
parent 89a3c304c2
commit 9ccd43e5f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 72 additions and 11 deletions

View file

@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt from homeassistant.util import dt
from .const import ( from .const import (
@ -54,6 +55,7 @@ from .const import (
START, START,
SUMMARY, SUMMARY,
) )
from .coordinator import TodoistCoordinator
from .types import CalData, CustomProject, ProjectData, TodoistEvent from .types import CalData, CustomProject, ProjectData, TodoistEvent
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -117,6 +119,8 @@ async def async_setup_platform(
project_id_lookup = {} project_id_lookup = {}
api = TodoistAPIAsync(token) api = TodoistAPIAsync(token)
coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api)
await coordinator.async_config_entry_first_refresh()
# Setup devices: # Setup devices:
# Grab all projects. # Grab all projects.
@ -131,7 +135,7 @@ async def async_setup_platform(
# Project is an object, not a dict! # Project is an object, not a dict!
# Because of that, we convert what we need to a dict. # Because of that, we convert what we need to a dict.
project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id}
project_devices.append(TodoistProjectEntity(project_data, labels, api)) project_devices.append(TodoistProjectEntity(coordinator, project_data, labels))
# Cache the names so we can easily look up name->ID. # Cache the names so we can easily look up name->ID.
project_id_lookup[project.name.lower()] = project.id project_id_lookup[project.name.lower()] = project.id
@ -157,9 +161,9 @@ async def async_setup_platform(
# Create the custom project and add it to the devices array. # Create the custom project and add it to the devices array.
project_devices.append( project_devices.append(
TodoistProjectEntity( TodoistProjectEntity(
coordinator,
{"id": None, "name": extra_project["name"]}, {"id": None, "name": extra_project["name"]},
labels, labels,
api,
due_date_days=project_due_date, due_date_days=project_due_date,
whitelisted_labels=project_label_filter, whitelisted_labels=project_label_filter,
whitelisted_projects=project_id_filter, whitelisted_projects=project_id_filter,
@ -267,23 +271,24 @@ async def async_setup_platform(
) )
class TodoistProjectEntity(CalendarEntity): class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity):
"""A device for getting the next Task from a Todoist Project.""" """A device for getting the next Task from a Todoist Project."""
def __init__( def __init__(
self, self,
coordinator: TodoistCoordinator,
data: ProjectData, data: ProjectData,
labels: list[Label], labels: list[Label],
api: TodoistAPIAsync,
due_date_days: int | None = None, due_date_days: int | None = None,
whitelisted_labels: list[str] | None = None, whitelisted_labels: list[str] | None = None,
whitelisted_projects: list[str] | None = None, whitelisted_projects: list[str] | None = None,
) -> None: ) -> None:
"""Create the Todoist Calendar Entity.""" """Create the Todoist Calendar Entity."""
super().__init__(coordinator=coordinator)
self.data = TodoistProjectData( self.data = TodoistProjectData(
data, data,
labels, labels,
api, coordinator,
due_date_days=due_date_days, due_date_days=due_date_days,
whitelisted_labels=whitelisted_labels, whitelisted_labels=whitelisted_labels,
whitelisted_projects=whitelisted_projects, whitelisted_projects=whitelisted_projects,
@ -306,6 +311,7 @@ class TodoistProjectEntity(CalendarEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update all Todoist Calendars.""" """Update all Todoist Calendars."""
await super().async_update()
await self.data.async_update() await self.data.async_update()
# Set Todoist-specific data that can't easily be grabbed # Set Todoist-specific data that can't easily be grabbed
self._cal_data["all_tasks"] = [ self._cal_data["all_tasks"] = [
@ -373,7 +379,7 @@ class TodoistProjectData:
self, self,
project_data: ProjectData, project_data: ProjectData,
labels: list[Label], labels: list[Label],
api: TodoistAPIAsync, coordinator: TodoistCoordinator,
due_date_days: int | None = None, due_date_days: int | None = None,
whitelisted_labels: list[str] | None = None, whitelisted_labels: list[str] | None = None,
whitelisted_projects: list[str] | None = None, whitelisted_projects: list[str] | None = None,
@ -381,7 +387,7 @@ class TodoistProjectData:
"""Initialize a Todoist Project.""" """Initialize a Todoist Project."""
self.event: TodoistEvent | None = None self.event: TodoistEvent | None = None
self._api = api self._coordinator = coordinator
self._name = project_data[CONF_NAME] self._name = project_data[CONF_NAME]
# If no ID is defined, fetch all tasks. # If no ID is defined, fetch all tasks.
self._id = project_data.get(CONF_ID) self._id = project_data.get(CONF_ID)
@ -569,8 +575,8 @@ class TodoistProjectData:
self, start_date: datetime, end_date: datetime self, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]: ) -> list[CalendarEvent]:
"""Get all tasks in a specific time frame.""" """Get all tasks in a specific time frame."""
tasks = self._coordinator.data
if self._id is None: if self._id is None:
tasks = await self._api.get_tasks()
project_task_data = [ project_task_data = [
task task
for task in tasks for task in tasks
@ -578,7 +584,7 @@ class TodoistProjectData:
or task.project_id in self._project_id_whitelist or task.project_id in self._project_id_whitelist
] ]
else: else:
project_task_data = await self._api.get_tasks(project_id=self._id) project_task_data = [task for task in tasks if task.project_id == self._id]
events = [] events = []
for task in project_task_data: for task in project_task_data:
@ -607,8 +613,8 @@ class TodoistProjectData:
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the latest data.""" """Get the latest data."""
tasks = self._coordinator.data
if self._id is None: if self._id is None:
tasks = await self._api.get_tasks()
project_task_data = [ project_task_data = [
task task
for task in tasks for task in tasks
@ -616,7 +622,7 @@ class TodoistProjectData:
or task.project_id in self._project_id_whitelist or task.project_id in self._project_id_whitelist
] ]
else: else:
project_task_data = await self._api.get_tasks(project_id=self._id) project_task_data = [task for task in tasks if task.project_id == self._id]
# If we have no data, we can just return right away. # If we have no data, we can just return right away.
if not project_task_data: if not project_task_data:

View file

@ -0,0 +1,31 @@
"""DataUpdateCoordinator for the Todoist component."""
from datetime import timedelta
import logging
from todoist_api_python.api_async import TodoistAPIAsync
from todoist_api_python.models import Task
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
"""Coordinator for updating task data from Todoist."""
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
update_interval: timedelta,
api: TodoistAPIAsync,
) -> None:
"""Initialize the Todoist coordinator."""
super().__init__(hass, logger, name="Todoist", update_interval=update_interval)
self.api = api
async def _async_update_data(self) -> list[Task]:
"""Fetch tasks from the Todoist API."""
try:
return await self.api.get_tasks()
except Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

View file

@ -132,6 +132,30 @@ async def test_update_entity_for_custom_project_with_labels_on(
assert state.state == "on" assert state.state == "on"
@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync")
async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) -> None:
"""Test a failed data coordinator update is handled correctly."""
api.get_tasks.side_effect = Exception("API error")
todoist_api.return_value = api
assert await setup.async_setup_component(
hass,
"calendar",
{
"calendar": {
"platform": DOMAIN,
CONF_TOKEN: "token",
"custom_projects": [{"name": "All projects", "labels": ["Label1"]}],
}
},
)
await hass.async_block_till_done()
await async_update_entity(hass, "calendar.all_projects")
state = hass.states.get("calendar.all_projects")
assert state is None
@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync")
async def test_calendar_custom_project_unique_id( async def test_calendar_custom_project_unique_id(
todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry