Add support for Todoist sections (#115671)

* Add support for Todoist sections

* ServiceValidationError & section name tweaks from PR comments

* Remove whitespace

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* More natural error message

Co-authored-by: Erik Montnemery <erik@montnemery.com>

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
pull/123103/head
Chris Buckley 2024-08-03 08:07:13 +01:00 committed by GitHub
parent aa6f0cd55a
commit 6684f61a54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 86 additions and 6 deletions

View File

@ -21,7 +21,7 @@ from homeassistant.components.calendar import (
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.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -54,6 +54,7 @@ from .const import (
REMINDER_DATE,
REMINDER_DATE_LANG,
REMINDER_DATE_STRING,
SECTION_NAME,
SERVICE_NEW_TASK,
START,
SUMMARY,
@ -68,6 +69,7 @@ NEW_TASK_SERVICE_SCHEMA = vol.Schema(
vol.Required(CONTENT): cv.string,
vol.Optional(DESCRIPTION): cv.string,
vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower),
vol.Optional(SECTION_NAME): vol.All(cv.string, vol.Lower),
vol.Optional(LABELS): cv.ensure_list_csv,
vol.Optional(ASSIGNEE): cv.string,
vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
@ -201,7 +203,7 @@ async def async_setup_platform(
async_register_services(hass, coordinator)
def async_register_services(
def async_register_services( # noqa: C901
hass: HomeAssistant, coordinator: TodoistCoordinator
) -> None:
"""Register services."""
@ -211,7 +213,7 @@ def async_register_services(
session = async_get_clientsession(hass)
async def handle_new_task(call: ServiceCall) -> None:
async def handle_new_task(call: ServiceCall) -> None: # noqa: C901
"""Call when a user creates a new Todoist Task from Home Assistant."""
project_name = call.data[PROJECT_NAME].lower()
projects = await coordinator.async_get_projects()
@ -222,12 +224,35 @@ def async_register_services(
if project_id is None:
raise HomeAssistantError(f"Invalid project name '{project_name}'")
# Optional section within project
section_id: str | None = None
if SECTION_NAME in call.data:
section_name = call.data[SECTION_NAME]
sections = await coordinator.async_get_sections(project_id)
for section in sections:
if section_name == section.name.lower():
section_id = section.id
break
if section_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="section_invalid",
translation_placeholders={
"section": section_name,
"project": project_name,
},
)
# Create the task
content = call.data[CONTENT]
data: dict[str, Any] = {"project_id": project_id}
if description := call.data.get(DESCRIPTION):
data["description"] = description
if section_id is not None:
data["section_id"] = section_id
if task_labels := call.data.get(LABELS):
data["labels"] = task_labels

View File

@ -78,6 +78,8 @@ PROJECT_ID: Final = "project_id"
PROJECT_NAME: Final = "project"
# Todoist API: Fetch all Projects
PROJECTS: Final = "projects"
# Section Name: What Section of the Project do you want to add the Task to?
SECTION_NAME: Final = "section"
# Calendar Platform: When does a calendar event start?
START: Final = "start"
# Calendar Platform: What is the next calendar event about?

View File

@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from todoist_api_python.api_async import TodoistAPIAsync
from todoist_api_python.models import Label, Project, Task
from todoist_api_python.models import Label, Project, Section, Task
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -41,6 +41,10 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
self._projects = await self.api.get_projects()
return self._projects
async def async_get_sections(self, project_id: str) -> list[Section]:
"""Return todoist sections for a given project ID."""
return await self.api.get_sections(project_id=project_id)
async def async_get_labels(self) -> list[Label]:
"""Return todoist labels fetched at most once."""
if self._labels is None:

View File

@ -13,6 +13,10 @@ new_task:
default: Inbox
selector:
text:
section:
example: Deliveries
selector:
text:
labels:
example: Chores,Delivieries
selector:

View File

@ -20,6 +20,11 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"exceptions": {
"section_invalid": {
"message": "Project \"{project}\" has no section \"{section}\""
}
},
"services": {
"new_task": {
"name": "New task",
@ -37,6 +42,10 @@
"name": "Project",
"description": "The name of the project this task should belong to."
},
"section": {
"name": "Section",
"description": "The name of a section within the project to add the task to."
},
"labels": {
"name": "Labels",
"description": "Any labels that you want to apply to this task, separated by a comma."

View File

@ -7,7 +7,7 @@ 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 todoist_api_python.models import Collaborator, Due, Label, Project, Section, Task
from homeassistant.components.todoist import DOMAIN
from homeassistant.const import CONF_TOKEN, Platform
@ -18,6 +18,7 @@ from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
PROJECT_ID = "project-id-1"
SECTION_ID = "section-id-1"
SUMMARY = "A task"
TOKEN = "some-token"
TODAY = dt_util.now().strftime("%Y-%m-%d")
@ -98,6 +99,14 @@ def mock_api(tasks: list[Task]) -> AsyncMock:
view_style="list",
)
]
api.get_sections.return_value = [
Section(
id=SECTION_ID,
project_id=PROJECT_ID,
name="Section Name",
order=1,
)
]
api.get_labels.return_value = [
Label(id="1", name="Label1", color="1", order=1, is_favorite=False)
]

View File

@ -18,6 +18,7 @@ from homeassistant.components.todoist.const import (
DOMAIN,
LABELS,
PROJECT_NAME,
SECTION_NAME,
SERVICE_NEW_TASK,
)
from homeassistant.const import CONF_TOKEN, Platform
@ -26,7 +27,7 @@ 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 PROJECT_ID, SUMMARY
from .conftest import PROJECT_ID, SECTION_ID, SUMMARY
from tests.typing import ClientSessionGenerator
@ -269,6 +270,32 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) ->
)
async def test_create_task_service_call_with_section(
hass: HomeAssistant, api: AsyncMock
) -> None:
"""Test api is called correctly when section is included."""
await hass.services.async_call(
DOMAIN,
SERVICE_NEW_TASK,
{
ASSIGNEE: "user",
CONTENT: "task",
LABELS: ["Label1"],
PROJECT_NAME: "Name",
SECTION_NAME: "Section Name",
},
)
await hass.async_block_till_done()
api.add_task.assert_called_with(
"task",
project_id=PROJECT_ID,
section_id=SECTION_ID,
labels=["Label1"],
assignee_id="1",
)
@pytest.mark.parametrize(
("due"),
[