Add configuration flow to Todoist integration (#100094)

* Add config flow to todoist

* Fix service calls for todoist

* Fix configuration entry test setup

* Bump test coverage to 100%

* Apply pr feedback
This commit is contained in:
Allen Porter 2023-09-11 22:56:08 -07:00 committed by GitHub
parent 8e43f79f19
commit 183b77973f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 540 additions and 77 deletions

View file

@ -1 +1,44 @@
"""The todoist component."""
"""The todoist integration."""
import datetime
import logging
from todoist_api_python.api_async import TodoistAPIAsync
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import TodoistCoordinator
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = datetime.timedelta(minutes=1)
PLATFORMS: list[Platform] = [Platform.CALENDAR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up todoist from a config entry."""
token = entry.data[CONF_TOKEN]
api = TodoistAPIAsync(token)
coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -17,8 +17,10 @@ from homeassistant.components.calendar import (
CalendarEntity,
CalendarEvent,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -106,6 +108,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
SCAN_INTERVAL = timedelta(minutes=1)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Todoist calendar platform config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
projects = await coordinator.async_get_projects()
labels = await coordinator.async_get_labels()
entities = []
for project in projects:
project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id}
entities.append(TodoistProjectEntity(coordinator, project_data, labels))
async_add_entities(entities)
async_register_services(hass, coordinator)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -119,7 +138,7 @@ async def async_setup_platform(
project_id_lookup = {}
api = TodoistAPIAsync(token)
coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api)
coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token)
await coordinator.async_refresh()
async def _shutdown_coordinator(_: Event) -> None:
@ -177,12 +196,29 @@ async def async_setup_platform(
async_add_entities(project_devices, update_before_add=True)
async_register_services(hass, coordinator)
def async_register_services(
hass: HomeAssistant, coordinator: TodoistCoordinator
) -> None:
"""Register services."""
if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK):
return
session = async_get_clientsession(hass)
async def handle_new_task(call: ServiceCall) -> None:
"""Call when a user creates a new Todoist Task from Home Assistant."""
project_name = call.data[PROJECT_NAME]
project_id = project_id_lookup[project_name]
project_name = call.data[PROJECT_NAME].lower()
projects = await coordinator.async_get_projects()
project_id: str | None = None
for project in projects:
if project_name == project.name.lower():
project_id = project.id
if project_id is None:
raise HomeAssistantError(f"Invalid project name '{project_name}'")
# Create the task
content = call.data[CONTENT]
@ -192,7 +228,7 @@ async def async_setup_platform(
data["labels"] = task_labels
if ASSIGNEE in call.data:
collaborators = await api.get_collaborators(project_id)
collaborators = await coordinator.api.get_collaborators(project_id)
collaborator_id_lookup = {
collab.name.lower(): collab.id for collab in collaborators
}
@ -225,7 +261,7 @@ async def async_setup_platform(
date_format = "%Y-%m-%dT%H:%M:%S"
data["due_datetime"] = datetime.strftime(due_date, date_format)
api_task = await api.add_task(content, **data)
api_task = await coordinator.api.add_task(content, **data)
# @NOTE: The rest-api doesn't support reminders, this works manually using
# the sync api, in order to keep functional parity with the component.
@ -263,7 +299,7 @@ async def async_setup_platform(
}
]
}
headers = create_headers(token=token, with_content=True)
headers = create_headers(token=coordinator.token, with_content=True)
return await session.post(sync_url, headers=headers, json=reminder_data)
if _reminder_due:

View file

@ -0,0 +1,63 @@
"""Config flow for todoist integration."""
from http import HTTPStatus
import logging
from typing import Any
from requests.exceptions import HTTPError
from todoist_api_python.api_async import TodoistAPIAsync
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SETTINGS_URL = "https://todoist.com/app/settings/integrations"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for todoist."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
errors: dict[str, str] = {}
if user_input is not None:
api = TodoistAPIAsync(user_input[CONF_TOKEN])
try:
await api.get_tasks()
except HTTPError as err:
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_access_token"
else:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_TOKEN])
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Todoist", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={"settings_url": SETTINGS_URL},
)

View file

@ -3,7 +3,7 @@ from datetime import timedelta
import logging
from todoist_api_python.api_async import TodoistAPIAsync
from todoist_api_python.models import Task
from todoist_api_python.models import Label, Project, Task
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -18,10 +18,14 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
logger: logging.Logger,
update_interval: timedelta,
api: TodoistAPIAsync,
token: str,
) -> None:
"""Initialize the Todoist coordinator."""
super().__init__(hass, logger, name="Todoist", update_interval=update_interval)
self.api = api
self._projects: list[Project] | None = None
self._labels: list[Label] | None = None
self.token = token
async def _async_update_data(self) -> list[Task]:
"""Fetch tasks from the Todoist API."""
@ -29,3 +33,15 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
return await self.api.get_tasks()
except Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
async def async_get_projects(self) -> list[Project]:
"""Return todoist projects fetched at most once."""
if self._projects is None:
self._projects = await self.api.get_projects()
return self._projects
async def async_get_labels(self) -> list[Label]:
"""Return todoist labels fetched at most once."""
if self._labels is None:
self._labels = await self.api.get_labels()
return self._labels

View file

@ -2,6 +2,7 @@
"domain": "todoist",
"name": "Todoist",
"codeowners": ["@boralyl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/todoist",
"iot_class": "cloud_polling",
"loggers": ["todoist"],

View file

@ -1,4 +1,23 @@
{
"config": {
"step": {
"user": {
"data": {
"token": "[%key:common::config_flow::data::api_token%]"
},
"description": "Please entry your API token from your [Todoist Settings page]({settings_url})"
}
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"services": {
"new_task": {
"name": "New task",

View file

@ -473,6 +473,7 @@ FLOWS = {
"tibber",
"tile",
"tilt_ble",
"todoist",
"tolo",
"tomorrowio",
"toon",

View file

@ -5808,7 +5808,7 @@
"todoist": {
"name": "Todoist",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"tolo": {

View file

@ -0,0 +1,135 @@
"""Common fixtures for the todoist tests."""
from collections.abc import Generator
from http import HTTPStatus
from unittest.mock import AsyncMock, patch
import pytest
from requests.exceptions import HTTPError
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.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
SUMMARY = "A task"
TOKEN = "some-token"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.todoist.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="due")
def mock_due() -> Due:
"""Mock a todoist Task Due date/time."""
return Due(
is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today"
)
@pytest.fixture(name="task")
def mock_task(due: Due) -> Task:
"""Mock a todoist Task instance."""
return Task(
assignee_id="1",
assigner_id="1",
comment_count=0,
is_completed=False,
content=SUMMARY,
created_at="2021-10-01T00:00:00",
creator_id="1",
description="A task",
due=due,
id="1",
labels=["Label1"],
order=1,
parent_id=None,
priority=1,
project_id="12345",
section_id=None,
url="https://todoist.com",
sync_id=None,
)
@pytest.fixture(name="api")
def mock_api(task) -> AsyncMock:
"""Mock the api state."""
api = AsyncMock()
api.get_projects.return_value = [
Project(
id="12345",
color="blue",
comment_count=0,
is_favorite=False,
name="Name",
is_shared=False,
url="",
is_inbox_project=False,
is_team_inbox=False,
order=1,
parent_id=None,
view_style="list",
)
]
api.get_labels.return_value = [
Label(id="1", name="Label1", color="1", order=1, is_favorite=False)
]
api.get_collaborators.return_value = [
Collaborator(email="user@gmail.com", id="1", name="user")
]
api.get_tasks.return_value = [task]
return api
@pytest.fixture(name="todoist_api_status")
def mock_api_status() -> HTTPStatus | None:
"""Fixture to inject an http status error."""
return None
@pytest.fixture(autouse=True)
def mock_api_side_effect(
api: AsyncMock, todoist_api_status: HTTPStatus | None
) -> MockConfigEntry:
"""Mock todoist configuration."""
if todoist_api_status:
response = Response()
response.status_code = todoist_api_status
api.get_tasks.side_effect = HTTPError(response=response)
@pytest.fixture(name="todoist_config_entry")
def mock_todoist_config_entry() -> MockConfigEntry:
"""Mock todoist configuration."""
return MockConfigEntry(domain=DOMAIN, unique_id=TOKEN, data={CONF_TOKEN: TOKEN})
@pytest.fixture(name="todoist_domain")
def mock_todoist_domain() -> str:
"""Mock todoist configuration."""
return DOMAIN
@pytest.fixture(name="setup_integration")
async def mock_setup_integration(
hass: HomeAssistant,
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):
assert await async_setup_component(hass, DOMAIN, {})
yield

View file

@ -7,7 +7,7 @@ import urllib
import zoneinfo
import pytest
from todoist_api_python.models import Collaborator, Due, Label, Project, Task
from todoist_api_python.models import Due
from homeassistant import setup
from homeassistant.components.todoist.const import (
@ -24,9 +24,10 @@ 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 tests.typing import ClientSessionGenerator
SUMMARY = "A task"
# Set our timezone to CST/Regina so we can check calculations
# This keeps UTC-6 all year round
TZ_NAME = "America/Regina"
@ -39,69 +40,6 @@ def set_time_zone(hass: HomeAssistant):
hass.config.set_time_zone(TZ_NAME)
@pytest.fixture(name="due")
def mock_due() -> Due:
"""Mock a todoist Task Due date/time."""
return Due(
is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today"
)
@pytest.fixture(name="task")
def mock_task(due: Due) -> Task:
"""Mock a todoist Task instance."""
return Task(
assignee_id="1",
assigner_id="1",
comment_count=0,
is_completed=False,
content=SUMMARY,
created_at="2021-10-01T00:00:00",
creator_id="1",
description="A task",
due=due,
id="1",
labels=["Label1"],
order=1,
parent_id=None,
priority=1,
project_id="12345",
section_id=None,
url="https://todoist.com",
sync_id=None,
)
@pytest.fixture(name="api")
def mock_api(task) -> AsyncMock:
"""Mock the api state."""
api = AsyncMock()
api.get_projects.return_value = [
Project(
id="12345",
color="blue",
comment_count=0,
is_favorite=False,
name="Name",
is_shared=False,
url="",
is_inbox_project=False,
is_team_inbox=False,
order=1,
parent_id=None,
view_style="list",
)
]
api.get_labels.return_value = [
Label(id="1", name="Label1", color="1", order=1, is_favorite=False)
]
api.get_collaborators.return_value = [
Collaborator(email="user@gmail.com", id="1", name="user")
]
api.get_tasks.return_value = [task]
return api
def get_events_url(entity: str, start: str, end: str) -> str:
"""Create a url to get events during the specified time range."""
return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}"
@ -127,8 +65,8 @@ def mock_todoist_config() -> dict[str, Any]:
return {}
@pytest.fixture(name="setup_integration", autouse=True)
async def mock_setup_integration(
@pytest.fixture(name="setup_platform", autouse=True)
async def mock_setup_platform(
hass: HomeAssistant,
api: AsyncMock,
todoist_config: dict[str, Any],
@ -215,7 +153,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future(
assert state.attributes["end_time"] == expected_end_time
@pytest.mark.parametrize("setup_integration", [None])
@pytest.mark.parametrize("setup_platform", [None])
async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None:
"""Test a failed data coordinator update is handled correctly."""
api.get_tasks.side_effect = Exception("API error")
@ -417,3 +355,44 @@ async def test_task_due_datetime(
)
assert response.status == HTTPStatus.OK
assert await response.json() == []
@pytest.mark.parametrize(
("due", "setup_platform"),
[
(
Due(
date="2023-03-30",
is_recurring=False,
string="Mar 30 6:00 PM",
datetime="2023-03-31T00:00:00Z",
timezone="America/Regina",
),
None,
)
],
)
async def test_config_entry(
hass: HomeAssistant,
setup_integration: None,
hass_client: ClientSessionGenerator,
) -> None:
"""Test for a calendar created with a config entry."""
await async_update_entity(hass, "calendar.name")
state = hass.states.get("calendar.name")
assert state
client = await hass_client()
response = await client.get(
get_events_url(
"calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == [
get_events_response(
{"dateTime": "2023-03-30T18:00:00-06:00"},
{"dateTime": "2023-03-31T18:00:00-06:00"},
)
]

View file

@ -0,0 +1,123 @@
"""Test the todoist config flow."""
from http import HTTPStatus
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.todoist.const import DOMAIN
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import TOKEN
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
@pytest.fixture(autouse=True)
async def patch_api(
api: AsyncMock,
) -> None:
"""Mock setup of the todoist integration."""
with patch(
"homeassistant.components.todoist.config_flow.TodoistAPIAsync", return_value=api
):
yield
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == FlowResultType.FORM
assert not result.get("errors")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: TOKEN,
},
)
await hass.async_block_till_done()
assert result2.get("type") == FlowResultType.CREATE_ENTRY
assert result2.get("title") == "Todoist"
assert result2.get("data") == {
CONF_TOKEN: TOKEN,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED])
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: TOKEN,
},
)
assert result2.get("type") == FlowResultType.FORM
assert result2.get("errors") == {"base": "invalid_access_token"}
@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR])
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: TOKEN,
},
)
assert result2.get("type") == FlowResultType.FORM
assert result2.get("errors") == {"base": "cannot_connect"}
@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED])
async def test_unknown_error(hass: HomeAssistant, api: AsyncMock) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
api.get_tasks.side_effect = ValueError("unexpected")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_TOKEN: TOKEN,
},
)
assert result2.get("type") == FlowResultType.FORM
assert result2.get("errors") == {"base": "unknown"}
async def test_already_configured(hass: HomeAssistant, setup_integration: None) -> None:
"""Test that only a single instance can be configured."""
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "single_instance_allowed"

View file

@ -0,0 +1,47 @@
"""Unit tests for the Todoist integration."""
from collections.abc import Generator
from http import HTTPStatus
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.todoist.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
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,
todoist_config_entry: MockConfigEntry | None,
) -> None:
"""Test loading and unloading of the config entry."""
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert todoist_config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(todoist_config_entry.entry_id)
assert todoist_config_entry.state == ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR])
async def test_init_failure(
hass: HomeAssistant,
setup_integration: None,
api: AsyncMock,
todoist_config_entry: MockConfigEntry | None,
) -> None:
"""Test an initialization error on integration load."""
assert todoist_config_entry.state == ConfigEntryState.SETUP_RETRY