mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +00:00
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>
This commit is contained in:
parent
aa6f0cd55a
commit
6684f61a54
@ -21,7 +21,7 @@ from homeassistant.components.calendar import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
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
|
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
|
||||||
@ -54,6 +54,7 @@ from .const import (
|
|||||||
REMINDER_DATE,
|
REMINDER_DATE,
|
||||||
REMINDER_DATE_LANG,
|
REMINDER_DATE_LANG,
|
||||||
REMINDER_DATE_STRING,
|
REMINDER_DATE_STRING,
|
||||||
|
SECTION_NAME,
|
||||||
SERVICE_NEW_TASK,
|
SERVICE_NEW_TASK,
|
||||||
START,
|
START,
|
||||||
SUMMARY,
|
SUMMARY,
|
||||||
@ -68,6 +69,7 @@ NEW_TASK_SERVICE_SCHEMA = vol.Schema(
|
|||||||
vol.Required(CONTENT): cv.string,
|
vol.Required(CONTENT): cv.string,
|
||||||
vol.Optional(DESCRIPTION): cv.string,
|
vol.Optional(DESCRIPTION): cv.string,
|
||||||
vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower),
|
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(LABELS): cv.ensure_list_csv,
|
||||||
vol.Optional(ASSIGNEE): cv.string,
|
vol.Optional(ASSIGNEE): cv.string,
|
||||||
vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
|
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)
|
async_register_services(hass, coordinator)
|
||||||
|
|
||||||
|
|
||||||
def async_register_services(
|
def async_register_services( # noqa: C901
|
||||||
hass: HomeAssistant, coordinator: TodoistCoordinator
|
hass: HomeAssistant, coordinator: TodoistCoordinator
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register services."""
|
"""Register services."""
|
||||||
@ -211,7 +213,7 @@ def async_register_services(
|
|||||||
|
|
||||||
session = async_get_clientsession(hass)
|
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."""
|
"""Call when a user creates a new Todoist Task from Home Assistant."""
|
||||||
project_name = call.data[PROJECT_NAME].lower()
|
project_name = call.data[PROJECT_NAME].lower()
|
||||||
projects = await coordinator.async_get_projects()
|
projects = await coordinator.async_get_projects()
|
||||||
@ -222,12 +224,35 @@ def async_register_services(
|
|||||||
if project_id is None:
|
if project_id is None:
|
||||||
raise HomeAssistantError(f"Invalid project name '{project_name}'")
|
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
|
# Create the task
|
||||||
content = call.data[CONTENT]
|
content = call.data[CONTENT]
|
||||||
data: dict[str, Any] = {"project_id": project_id}
|
data: dict[str, Any] = {"project_id": project_id}
|
||||||
|
|
||||||
if description := call.data.get(DESCRIPTION):
|
if description := call.data.get(DESCRIPTION):
|
||||||
data["description"] = description
|
data["description"] = description
|
||||||
|
|
||||||
|
if section_id is not None:
|
||||||
|
data["section_id"] = section_id
|
||||||
|
|
||||||
if task_labels := call.data.get(LABELS):
|
if task_labels := call.data.get(LABELS):
|
||||||
data["labels"] = task_labels
|
data["labels"] = task_labels
|
||||||
|
|
||||||
|
@ -78,6 +78,8 @@ PROJECT_ID: Final = "project_id"
|
|||||||
PROJECT_NAME: Final = "project"
|
PROJECT_NAME: Final = "project"
|
||||||
# Todoist API: Fetch all Projects
|
# Todoist API: Fetch all Projects
|
||||||
PROJECTS: Final = "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?
|
# Calendar Platform: When does a calendar event start?
|
||||||
START: Final = "start"
|
START: Final = "start"
|
||||||
# Calendar Platform: What is the next calendar event about?
|
# Calendar Platform: What is the next calendar event about?
|
||||||
|
@ -4,7 +4,7 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from todoist_api_python.api_async import TodoistAPIAsync
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
@ -41,6 +41,10 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
|
|||||||
self._projects = await self.api.get_projects()
|
self._projects = await self.api.get_projects()
|
||||||
return self._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]:
|
async def async_get_labels(self) -> list[Label]:
|
||||||
"""Return todoist labels fetched at most once."""
|
"""Return todoist labels fetched at most once."""
|
||||||
if self._labels is None:
|
if self._labels is None:
|
||||||
|
@ -13,6 +13,10 @@ new_task:
|
|||||||
default: Inbox
|
default: Inbox
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
section:
|
||||||
|
example: Deliveries
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
labels:
|
labels:
|
||||||
example: Chores,Delivieries
|
example: Chores,Delivieries
|
||||||
selector:
|
selector:
|
||||||
|
@ -20,6 +20,11 @@
|
|||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"section_invalid": {
|
||||||
|
"message": "Project \"{project}\" has no section \"{section}\""
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"new_task": {
|
"new_task": {
|
||||||
"name": "New task",
|
"name": "New task",
|
||||||
@ -37,6 +42,10 @@
|
|||||||
"name": "Project",
|
"name": "Project",
|
||||||
"description": "The name of the project this task should belong to."
|
"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": {
|
"labels": {
|
||||||
"name": "Labels",
|
"name": "Labels",
|
||||||
"description": "Any labels that you want to apply to this task, separated by a comma."
|
"description": "Any labels that you want to apply to this task, separated by a comma."
|
||||||
|
@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from requests.exceptions import HTTPError
|
from requests.exceptions import HTTPError
|
||||||
from requests.models import Response
|
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.components.todoist import DOMAIN
|
||||||
from homeassistant.const import CONF_TOKEN, Platform
|
from homeassistant.const import CONF_TOKEN, Platform
|
||||||
@ -18,6 +18,7 @@ from homeassistant.util import dt as dt_util
|
|||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
PROJECT_ID = "project-id-1"
|
PROJECT_ID = "project-id-1"
|
||||||
|
SECTION_ID = "section-id-1"
|
||||||
SUMMARY = "A task"
|
SUMMARY = "A task"
|
||||||
TOKEN = "some-token"
|
TOKEN = "some-token"
|
||||||
TODAY = dt_util.now().strftime("%Y-%m-%d")
|
TODAY = dt_util.now().strftime("%Y-%m-%d")
|
||||||
@ -98,6 +99,14 @@ def mock_api(tasks: list[Task]) -> AsyncMock:
|
|||||||
view_style="list",
|
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 = [
|
api.get_labels.return_value = [
|
||||||
Label(id="1", name="Label1", color="1", order=1, is_favorite=False)
|
Label(id="1", name="Label1", color="1", order=1, is_favorite=False)
|
||||||
]
|
]
|
||||||
|
@ -18,6 +18,7 @@ from homeassistant.components.todoist.const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
LABELS,
|
LABELS,
|
||||||
PROJECT_NAME,
|
PROJECT_NAME,
|
||||||
|
SECTION_NAME,
|
||||||
SERVICE_NEW_TASK,
|
SERVICE_NEW_TASK,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_TOKEN, Platform
|
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.helpers.entity_component import async_update_entity
|
||||||
from homeassistant.util import dt as dt_util
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
("due"),
|
("due"),
|
||||||
[
|
[
|
||||||
|
Loading…
x
Reference in New Issue
Block a user