mirror of
https://github.com/home-assistant/core.git
synced 2025-06-09 23:57:08 +00:00
Update todoist integration to use new official rest api library (#79481)
* Swapping out libraries. * Adding types * Add ability to add task. * Removed remaining todos. * Fix lint errors. * Fixing tests. * Update to v2 of the rest api. * Swapping out libraries. * Adding types * Add ability to add task. * Removed remaining todos. * Fix lint errors. * Fix mypy errors * Fix custom projects. * Bump DEPENDENCY_CONFLICTS const * Remove conflict bump * Addressing PR feedback. * Removing utc offset logic and configuration. * Addressing PR feedback. * Revert date range logic check
This commit is contained in:
parent
7440c34901
commit
8cbbdf21f3
@ -1,11 +1,17 @@
|
|||||||
"""Support for Todoist task management (https://todoist.com)."""
|
"""Support for Todoist task management (https://todoist.com)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
import asyncio
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from itertools import chain
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
from todoist.api import TodoistAPI
|
from todoist_api_python.api_async import TodoistAPIAsync
|
||||||
|
from todoist_api_python.endpoints import get_sync_url
|
||||||
|
from todoist_api_python.headers import create_headers
|
||||||
|
from todoist_api_python.models import Label, Task
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.calendar import (
|
from homeassistant.components.calendar import (
|
||||||
@ -15,6 +21,7 @@ from homeassistant.components.calendar import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
|
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
|
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
|
||||||
@ -24,8 +31,6 @@ from .const import (
|
|||||||
ALL_DAY,
|
ALL_DAY,
|
||||||
ALL_TASKS,
|
ALL_TASKS,
|
||||||
ASSIGNEE,
|
ASSIGNEE,
|
||||||
CHECKED,
|
|
||||||
COLLABORATORS,
|
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
CONF_EXTRA_PROJECTS,
|
CONF_EXTRA_PROJECTS,
|
||||||
CONF_PROJECT_DUE_DATE,
|
CONF_PROJECT_DUE_DATE,
|
||||||
@ -34,31 +39,24 @@ from .const import (
|
|||||||
CONTENT,
|
CONTENT,
|
||||||
DESCRIPTION,
|
DESCRIPTION,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DUE,
|
|
||||||
DUE_DATE,
|
DUE_DATE,
|
||||||
DUE_DATE_LANG,
|
DUE_DATE_LANG,
|
||||||
DUE_DATE_STRING,
|
DUE_DATE_STRING,
|
||||||
DUE_DATE_VALID_LANGS,
|
DUE_DATE_VALID_LANGS,
|
||||||
DUE_TODAY,
|
DUE_TODAY,
|
||||||
END,
|
END,
|
||||||
FULL_NAME,
|
|
||||||
ID,
|
|
||||||
LABELS,
|
LABELS,
|
||||||
NAME,
|
|
||||||
OVERDUE,
|
OVERDUE,
|
||||||
PRIORITY,
|
PRIORITY,
|
||||||
PROJECT_ID,
|
|
||||||
PROJECT_NAME,
|
PROJECT_NAME,
|
||||||
PROJECTS,
|
|
||||||
REMINDER_DATE,
|
REMINDER_DATE,
|
||||||
REMINDER_DATE_LANG,
|
REMINDER_DATE_LANG,
|
||||||
REMINDER_DATE_STRING,
|
REMINDER_DATE_STRING,
|
||||||
SERVICE_NEW_TASK,
|
SERVICE_NEW_TASK,
|
||||||
START,
|
START,
|
||||||
SUMMARY,
|
SUMMARY,
|
||||||
TASKS,
|
|
||||||
)
|
)
|
||||||
from .types import CalData, CustomProject, DueDate, ProjectData, TodoistEvent
|
from .types import CalData, CustomProject, ProjectData, TodoistEvent
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -108,109 +106,115 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||||||
SCAN_INTERVAL = timedelta(minutes=1)
|
SCAN_INTERVAL = timedelta(minutes=1)
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(
|
async def async_setup_platform(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Todoist platform."""
|
"""Set up the Todoist platform."""
|
||||||
token = config.get(CONF_TOKEN)
|
token = config[CONF_TOKEN]
|
||||||
|
|
||||||
# Look up IDs based on (lowercase) names.
|
# Look up IDs based on (lowercase) names.
|
||||||
project_id_lookup = {}
|
project_id_lookup = {}
|
||||||
label_id_lookup = {}
|
label_id_lookup = {}
|
||||||
collaborator_id_lookup = {}
|
collaborator_id_lookup = {}
|
||||||
|
|
||||||
api = TodoistAPI(token)
|
api = TodoistAPIAsync(token)
|
||||||
api.sync()
|
|
||||||
|
|
||||||
# Setup devices:
|
# Setup devices:
|
||||||
# Grab all projects.
|
# Grab all projects.
|
||||||
projects = api.state[PROJECTS]
|
projects = await api.get_projects()
|
||||||
|
|
||||||
|
collaborator_tasks = (api.get_collaborators(project.id) for project in projects)
|
||||||
|
collaborators = list(chain.from_iterable(await asyncio.gather(*collaborator_tasks)))
|
||||||
|
|
||||||
collaborators = api.state[COLLABORATORS]
|
|
||||||
# Grab all labels
|
# Grab all labels
|
||||||
labels = api.state[LABELS]
|
labels = await api.get_labels()
|
||||||
|
|
||||||
# Add all Todoist-defined projects.
|
# Add all Todoist-defined projects.
|
||||||
project_devices = []
|
project_devices = []
|
||||||
for project in projects:
|
for project in projects:
|
||||||
# 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(project_data, labels, api))
|
||||||
# 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
|
||||||
|
|
||||||
# Cache all label names
|
# Cache all label names
|
||||||
for label in labels:
|
label_id_lookup = {label.name.lower(): label.id for label in labels}
|
||||||
label_id_lookup[label[NAME].lower()] = label[ID]
|
|
||||||
|
|
||||||
for collaborator in collaborators:
|
collaborator_id_lookup = {
|
||||||
collaborator_id_lookup[collaborator[FULL_NAME].lower()] = collaborator[ID]
|
collab.name.lower(): collab.id for collab in collaborators
|
||||||
|
}
|
||||||
|
|
||||||
# Check config for more projects.
|
# Check config for more projects.
|
||||||
extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS]
|
extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS]
|
||||||
for project in extra_projects:
|
for extra_project in extra_projects:
|
||||||
# Special filter: By date
|
# Special filter: By date
|
||||||
project_due_date = project.get(CONF_PROJECT_DUE_DATE)
|
project_due_date = extra_project.get(CONF_PROJECT_DUE_DATE)
|
||||||
|
|
||||||
# Special filter: By label
|
# Special filter: By label
|
||||||
project_label_filter = project[CONF_PROJECT_LABEL_WHITELIST]
|
project_label_filter = extra_project[CONF_PROJECT_LABEL_WHITELIST]
|
||||||
|
|
||||||
# Special filter: By name
|
# Special filter: By name
|
||||||
# Names must be converted into IDs.
|
# Names must be converted into IDs.
|
||||||
project_name_filter = project[CONF_PROJECT_WHITELIST]
|
project_name_filter = extra_project[CONF_PROJECT_WHITELIST]
|
||||||
project_id_filter = [
|
project_id_filter: list[str] | None = None
|
||||||
project_id_lookup[project_name.lower()]
|
if project_name_filter is not None:
|
||||||
for project_name in project_name_filter
|
project_id_filter = [
|
||||||
]
|
project_id_lookup[project_name.lower()]
|
||||||
|
for project_name in project_name_filter
|
||||||
|
]
|
||||||
|
|
||||||
# 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(
|
||||||
project,
|
{"id": None, "name": extra_project["name"]},
|
||||||
labels,
|
labels,
|
||||||
api,
|
api,
|
||||||
project_due_date,
|
due_date_days=project_due_date,
|
||||||
project_label_filter,
|
whitelisted_labels=project_label_filter,
|
||||||
project_id_filter,
|
whitelisted_projects=project_id_filter,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
add_entities(project_devices)
|
|
||||||
|
|
||||||
def handle_new_task(call: ServiceCall) -> None:
|
async_add_entities(project_devices)
|
||||||
|
|
||||||
|
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."""
|
"""Call when a user creates a new Todoist Task from Home Assistant."""
|
||||||
project_name = call.data[PROJECT_NAME]
|
project_name = call.data[PROJECT_NAME]
|
||||||
project_id = project_id_lookup[project_name]
|
project_id = project_id_lookup[project_name]
|
||||||
|
|
||||||
# Create the task
|
# Create the task
|
||||||
item = api.items.add(call.data[CONTENT], project_id=project_id)
|
content = call.data[CONTENT]
|
||||||
|
data: dict[str, Any] = {"project_id": project_id}
|
||||||
|
|
||||||
if LABELS in call.data:
|
if task_labels := call.data.get(LABELS):
|
||||||
task_labels = call.data[LABELS]
|
data["label_ids"] = [
|
||||||
label_ids = [label_id_lookup[label.lower()] for label in task_labels]
|
label_id_lookup[label.lower()] for label in task_labels
|
||||||
item.update(labels=label_ids)
|
]
|
||||||
|
|
||||||
if ASSIGNEE in call.data:
|
if ASSIGNEE in call.data:
|
||||||
task_assignee = call.data[ASSIGNEE].lower()
|
task_assignee = call.data[ASSIGNEE].lower()
|
||||||
if task_assignee in collaborator_id_lookup:
|
if task_assignee in collaborator_id_lookup:
|
||||||
item.update(responsible_uid=collaborator_id_lookup[task_assignee])
|
data["assignee"] = collaborator_id_lookup[task_assignee]
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"User is not part of the shared project. user: {task_assignee}"
|
f"User is not part of the shared project. user: {task_assignee}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if PRIORITY in call.data:
|
if PRIORITY in call.data:
|
||||||
item.update(priority=call.data[PRIORITY])
|
data["priority"] = call.data[PRIORITY]
|
||||||
|
|
||||||
_due: dict = {}
|
|
||||||
if DUE_DATE_STRING in call.data:
|
if DUE_DATE_STRING in call.data:
|
||||||
_due["string"] = call.data[DUE_DATE_STRING]
|
data["due_string"] = call.data[DUE_DATE_STRING]
|
||||||
|
|
||||||
if DUE_DATE_LANG in call.data:
|
if DUE_DATE_LANG in call.data:
|
||||||
_due["lang"] = call.data[DUE_DATE_LANG]
|
data["due_lang"] = call.data[DUE_DATE_LANG]
|
||||||
|
|
||||||
if DUE_DATE in call.data:
|
if DUE_DATE in call.data:
|
||||||
due_date = dt.parse_datetime(call.data[DUE_DATE])
|
due_date = dt.parse_datetime(call.data[DUE_DATE])
|
||||||
@ -222,11 +226,14 @@ def setup_platform(
|
|||||||
# Format it in the manner Todoist expects
|
# Format it in the manner Todoist expects
|
||||||
due_date = dt.as_utc(due_date)
|
due_date = dt.as_utc(due_date)
|
||||||
date_format = "%Y-%m-%dT%H:%M:%S"
|
date_format = "%Y-%m-%dT%H:%M:%S"
|
||||||
_due["date"] = datetime.strftime(due_date, date_format)
|
data["due_datetime"] = datetime.strftime(due_date, date_format)
|
||||||
|
|
||||||
if _due:
|
api_task = await api.add_task(content, **data)
|
||||||
item.update(due=_due)
|
|
||||||
|
|
||||||
|
# @NOTE: The rest-api doesn't support reminders, this works manually using
|
||||||
|
# the sync api, in order to keep functional parity with the component.
|
||||||
|
# https://developer.todoist.com/sync/v9/#reminders
|
||||||
|
sync_url = get_sync_url("sync")
|
||||||
_reminder_due: dict = {}
|
_reminder_due: dict = {}
|
||||||
if REMINDER_DATE_STRING in call.data:
|
if REMINDER_DATE_STRING in call.data:
|
||||||
_reminder_due["string"] = call.data[REMINDER_DATE_STRING]
|
_reminder_due["string"] = call.data[REMINDER_DATE_STRING]
|
||||||
@ -248,50 +255,50 @@ def setup_platform(
|
|||||||
date_format = "%Y-%m-%dT%H:%M:%S"
|
date_format = "%Y-%m-%dT%H:%M:%S"
|
||||||
_reminder_due["date"] = datetime.strftime(due_date, date_format)
|
_reminder_due["date"] = datetime.strftime(due_date, date_format)
|
||||||
|
|
||||||
if _reminder_due:
|
async def add_reminder(reminder_due: dict):
|
||||||
api.reminders.add(item["id"], due=_reminder_due)
|
reminder_data = {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"type": "reminder_add",
|
||||||
|
"temp_id": str(uuid.uuid1()),
|
||||||
|
"uuid": str(uuid.uuid1()),
|
||||||
|
"args": {"item_id": api_task.id, "due": reminder_due},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
headers = create_headers(token=token, with_content=True)
|
||||||
|
return await session.post(sync_url, headers=headers, json=reminder_data)
|
||||||
|
|
||||||
|
if _reminder_due:
|
||||||
|
await add_reminder(_reminder_due)
|
||||||
|
|
||||||
# Commit changes
|
|
||||||
api.commit()
|
|
||||||
_LOGGER.debug("Created Todoist task: %s", call.data[CONTENT])
|
_LOGGER.debug("Created Todoist task: %s", call.data[CONTENT])
|
||||||
|
|
||||||
hass.services.register(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA
|
DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_due_date(data: DueDate, timezone_offset: int) -> datetime | None:
|
|
||||||
"""Parse the due date dict into a datetime object in UTC.
|
|
||||||
|
|
||||||
This function will always return a timezone aware datetime if it can be parsed.
|
|
||||||
"""
|
|
||||||
if not (nowtime := dt.parse_datetime(data["date"])):
|
|
||||||
return None
|
|
||||||
if nowtime.tzinfo is None:
|
|
||||||
nowtime = nowtime.replace(tzinfo=timezone(timedelta(hours=timezone_offset)))
|
|
||||||
return dt.as_utc(nowtime)
|
|
||||||
|
|
||||||
|
|
||||||
class TodoistProjectEntity(CalendarEntity):
|
class TodoistProjectEntity(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,
|
||||||
data: ProjectData,
|
data: ProjectData,
|
||||||
labels: list[str],
|
labels: list[Label],
|
||||||
token: TodoistAPI,
|
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[int] | None = None,
|
whitelisted_projects: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create the Todoist Calendar Entity."""
|
"""Create the Todoist Calendar Entity."""
|
||||||
self.data = TodoistProjectData(
|
self.data = TodoistProjectData(
|
||||||
data,
|
data,
|
||||||
labels,
|
labels,
|
||||||
token,
|
api,
|
||||||
due_date_days,
|
due_date_days=due_date_days,
|
||||||
whitelisted_labels,
|
whitelisted_labels=whitelisted_labels,
|
||||||
whitelisted_projects,
|
whitelisted_projects=whitelisted_projects,
|
||||||
)
|
)
|
||||||
self._cal_data: CalData = {}
|
self._cal_data: CalData = {}
|
||||||
self._name = data[CONF_NAME]
|
self._name = data[CONF_NAME]
|
||||||
@ -309,11 +316,11 @@ class TodoistProjectEntity(CalendarEntity):
|
|||||||
"""Return the name of the entity."""
|
"""Return the name of the entity."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
def update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update all Todoist Calendars."""
|
"""Update all Todoist Calendars."""
|
||||||
self.data.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"] = [
|
||||||
task[SUMMARY] for task in self.data.all_project_tasks
|
task[SUMMARY] for task in self.data.all_project_tasks
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -324,7 +331,7 @@ class TodoistProjectEntity(CalendarEntity):
|
|||||||
end_date: datetime,
|
end_date: datetime,
|
||||||
) -> list[CalendarEvent]:
|
) -> list[CalendarEvent]:
|
||||||
"""Get all events in a specific time frame."""
|
"""Get all events in a specific time frame."""
|
||||||
return await self.data.async_get_events(hass, start_date, end_date)
|
return await self.data.async_get_events(start_date, end_date)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
@ -378,14 +385,14 @@ class TodoistProjectData:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
project_data: ProjectData,
|
project_data: ProjectData,
|
||||||
labels: list[str],
|
labels: list[Label],
|
||||||
api: TodoistAPI,
|
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[int] | None = None,
|
whitelisted_projects: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a Todoist Project."""
|
"""Initialize a Todoist Project."""
|
||||||
self.event = None
|
self.event: TodoistEvent | None = None
|
||||||
|
|
||||||
self._api = api
|
self._api = api
|
||||||
self._name = project_data[CONF_NAME]
|
self._name = project_data[CONF_NAME]
|
||||||
@ -410,7 +417,7 @@ class TodoistProjectData:
|
|||||||
self._label_whitelist = whitelisted_labels
|
self._label_whitelist = whitelisted_labels
|
||||||
|
|
||||||
# This project includes only projects with these names.
|
# This project includes only projects with these names.
|
||||||
self._project_id_whitelist: list[int] = []
|
self._project_id_whitelist: list[str] = []
|
||||||
if whitelisted_projects is not None:
|
if whitelisted_projects is not None:
|
||||||
self._project_id_whitelist = whitelisted_projects
|
self._project_id_whitelist = whitelisted_projects
|
||||||
|
|
||||||
@ -419,33 +426,41 @@ class TodoistProjectData:
|
|||||||
"""Return the next upcoming calendar event."""
|
"""Return the next upcoming calendar event."""
|
||||||
if not self.event:
|
if not self.event:
|
||||||
return None
|
return None
|
||||||
if not self.event.get(END) or self.event.get(ALL_DAY):
|
|
||||||
start = self.event[START].date()
|
start = self.event[START]
|
||||||
|
if self.event.get(ALL_DAY) or self.event[END] is None:
|
||||||
return CalendarEvent(
|
return CalendarEvent(
|
||||||
summary=self.event[SUMMARY],
|
summary=self.event[SUMMARY],
|
||||||
start=start,
|
start=start.date(),
|
||||||
end=start + timedelta(days=1),
|
end=start.date() + timedelta(days=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
return CalendarEvent(
|
return CalendarEvent(
|
||||||
summary=self.event[SUMMARY], start=self.event[START], end=self.event[END]
|
summary=self.event[SUMMARY], start=start, end=self.event[END]
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_todoist_task(self, data):
|
def create_todoist_task(self, data: Task):
|
||||||
"""
|
"""
|
||||||
Create a dictionary based on a Task passed from the Todoist API.
|
Create a dictionary based on a Task passed from the Todoist API.
|
||||||
|
|
||||||
Will return 'None' if the task is to be filtered out.
|
Will return 'None' if the task is to be filtered out.
|
||||||
"""
|
"""
|
||||||
task = {}
|
task: TodoistEvent = {
|
||||||
# Fields are required to be in all returned task objects.
|
ALL_DAY: False,
|
||||||
task[SUMMARY] = data[CONTENT]
|
COMPLETED: data.is_completed,
|
||||||
task[COMPLETED] = data[CHECKED] == 1
|
DESCRIPTION: f"https://todoist.com/showTask?id={data.id}",
|
||||||
task[PRIORITY] = data[PRIORITY]
|
DUE_TODAY: False,
|
||||||
task[DESCRIPTION] = f"https://todoist.com/showTask?id={data[ID]}"
|
END: None,
|
||||||
|
LABELS: [],
|
||||||
|
OVERDUE: False,
|
||||||
|
PRIORITY: data.priority,
|
||||||
|
START: dt.utcnow(),
|
||||||
|
SUMMARY: data.content,
|
||||||
|
}
|
||||||
|
|
||||||
# All task Labels (optional parameter).
|
# All task Labels (optional parameter).
|
||||||
task[LABELS] = [
|
task[LABELS] = [
|
||||||
label[NAME].lower() for label in self._labels if label[ID] in data[LABELS]
|
label.name.lower() for label in self._labels if label.id in data.labels
|
||||||
]
|
]
|
||||||
|
|
||||||
if self._label_whitelist and (
|
if self._label_whitelist and (
|
||||||
@ -460,30 +475,30 @@ class TodoistProjectData:
|
|||||||
# That means that the START date is the earliest time one can
|
# That means that the START date is the earliest time one can
|
||||||
# complete the task.
|
# complete the task.
|
||||||
# Generally speaking, that means right now.
|
# Generally speaking, that means right now.
|
||||||
task[START] = dt.utcnow()
|
if data.due is not None:
|
||||||
if data[DUE] is not None:
|
end = dt.parse_datetime(
|
||||||
task[END] = _parse_due_date(
|
data.due.datetime if data.due.datetime else data.due.date
|
||||||
data[DUE], self._api.state["user"]["tz_info"]["hours"]
|
|
||||||
)
|
)
|
||||||
|
task[END] = dt.as_utc(end) if end is not None else end
|
||||||
|
if task[END] is not None:
|
||||||
|
if self._due_date_days is not None and (
|
||||||
|
task[END] > dt.utcnow() + self._due_date_days
|
||||||
|
):
|
||||||
|
# This task is out of range of our due date;
|
||||||
|
# it shouldn't be counted.
|
||||||
|
return None
|
||||||
|
|
||||||
if self._due_date_days is not None and (
|
task[DUE_TODAY] = task[END].date() == dt.utcnow().date()
|
||||||
task[END] > dt.utcnow() + self._due_date_days
|
|
||||||
):
|
|
||||||
# This task is out of range of our due date;
|
|
||||||
# it shouldn't be counted.
|
|
||||||
return None
|
|
||||||
|
|
||||||
task[DUE_TODAY] = task[END].date() == dt.utcnow().date()
|
# Special case: Task is overdue.
|
||||||
|
if task[END] <= task[START]:
|
||||||
# Special case: Task is overdue.
|
task[OVERDUE] = True
|
||||||
if task[END] <= task[START]:
|
# Set end time to the current time plus 1 hour.
|
||||||
task[OVERDUE] = True
|
# We're pretty much guaranteed to update within that 1 hour,
|
||||||
# Set end time to the current time plus 1 hour.
|
# so it should be fine.
|
||||||
# We're pretty much guaranteed to update within that 1 hour,
|
task[END] = task[START] + timedelta(hours=1)
|
||||||
# so it should be fine.
|
else:
|
||||||
task[END] = task[START] + timedelta(hours=1)
|
task[OVERDUE] = False
|
||||||
else:
|
|
||||||
task[OVERDUE] = False
|
|
||||||
else:
|
else:
|
||||||
# If we ask for everything due before a certain date, don't count
|
# If we ask for everything due before a certain date, don't count
|
||||||
# things which have no due dates.
|
# things which have no due dates.
|
||||||
@ -564,72 +579,60 @@ class TodoistProjectData:
|
|||||||
):
|
):
|
||||||
event = proposed_event
|
event = proposed_event
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
async def async_get_events(
|
async def async_get_events(
|
||||||
self, hass: HomeAssistant, 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."""
|
||||||
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 self._api.state[TASKS]
|
for task in tasks
|
||||||
if not self._project_id_whitelist
|
if not self._project_id_whitelist
|
||||||
or task[PROJECT_ID] in self._project_id_whitelist
|
or task.project_id in self._project_id_whitelist
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
project_data = await hass.async_add_executor_job(
|
project_task_data = await self._api.get_tasks(project_id=self._id)
|
||||||
self._api.projects.get_data, self._id
|
|
||||||
)
|
|
||||||
project_task_data = project_data[TASKS]
|
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for task in project_task_data:
|
for task in project_task_data:
|
||||||
if task["due"] is None:
|
if task.due is None:
|
||||||
continue
|
continue
|
||||||
# @NOTE: _parse_due_date always returns the date in UTC time.
|
due_date = dt.parse_datetime(
|
||||||
due_date: datetime | None = _parse_due_date(
|
task.due.datetime if task.due.datetime else task.due.date
|
||||||
task["due"], self._api.state["user"]["tz_info"]["hours"]
|
|
||||||
)
|
)
|
||||||
if not due_date:
|
if not due_date:
|
||||||
continue
|
continue
|
||||||
gmt_string = self._api.state["user"]["tz_info"]["gmt_string"]
|
due_date = dt.as_utc(due_date)
|
||||||
local_midnight = dt.parse_datetime(
|
|
||||||
due_date.strftime(f"%Y-%m-%dT00:00:00{gmt_string}")
|
|
||||||
)
|
|
||||||
if local_midnight is not None:
|
|
||||||
midnight = dt.as_utc(local_midnight)
|
|
||||||
else:
|
|
||||||
midnight = due_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
if start_date < due_date < end_date:
|
if start_date < due_date < end_date:
|
||||||
due_date_value: datetime | date = due_date
|
due_date_value: datetime | date = due_date
|
||||||
|
midnight = dt.start_of_local_day(due_date)
|
||||||
if due_date == midnight:
|
if due_date == midnight:
|
||||||
# If the due date has no time data, return just the date so that it
|
# If the due date has no time data, return just the date so that it
|
||||||
# will render correctly as an all day event on a calendar.
|
# will render correctly as an all day event on a calendar.
|
||||||
due_date_value = due_date.date()
|
due_date_value = due_date.date()
|
||||||
event = CalendarEvent(
|
event = CalendarEvent(
|
||||||
summary=task["content"],
|
summary=task.content,
|
||||||
start=due_date_value,
|
start=due_date_value,
|
||||||
end=due_date_value,
|
end=due_date_value,
|
||||||
)
|
)
|
||||||
events.append(event)
|
events.append(event)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def update(self):
|
async def async_update(self) -> None:
|
||||||
"""Get the latest data."""
|
"""Get the latest data."""
|
||||||
if self._id is None:
|
if self._id is None:
|
||||||
self._api.reset_state()
|
tasks = await self._api.get_tasks()
|
||||||
self._api.sync()
|
|
||||||
project_task_data = [
|
project_task_data = [
|
||||||
task
|
task
|
||||||
for task in self._api.state[TASKS]
|
for task in tasks
|
||||||
if not self._project_id_whitelist
|
if not self._project_id_whitelist
|
||||||
or task[PROJECT_ID] in self._project_id_whitelist
|
or task.project_id in self._project_id_whitelist
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
project_task_data = self._api.projects.get_data(self._id)[TASKS]
|
project_task_data = await self._api.get_tasks(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:
|
||||||
@ -639,7 +642,6 @@ class TodoistProjectData:
|
|||||||
|
|
||||||
# Keep an updated list of all tasks in this project.
|
# Keep an updated list of all tasks in this project.
|
||||||
project_tasks = []
|
project_tasks = []
|
||||||
|
|
||||||
for task in project_task_data:
|
for task in project_task_data:
|
||||||
todoist_task = self.create_todoist_task(task)
|
todoist_task = self.create_todoist_task(task)
|
||||||
if todoist_task is not None:
|
if todoist_task is not None:
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "todoist",
|
"domain": "todoist",
|
||||||
"name": "Todoist",
|
"name": "Todoist",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/todoist",
|
"documentation": "https://www.home-assistant.io/integrations/todoist",
|
||||||
"requirements": ["todoist-python==8.0.0"],
|
"requirements": ["todoist-api-python==2.0.2"],
|
||||||
"codeowners": ["@boralyl"],
|
"codeowners": ["@boralyl"],
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["todoist"]
|
"loggers": ["todoist"]
|
||||||
|
@ -19,7 +19,7 @@ class ProjectData(TypedDict):
|
|||||||
"""Dict representing project data."""
|
"""Dict representing project data."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
id: int | None
|
id: str | None
|
||||||
|
|
||||||
|
|
||||||
class CustomProject(TypedDict):
|
class CustomProject(TypedDict):
|
||||||
|
@ -2456,7 +2456,7 @@ tilt-ble==0.2.3
|
|||||||
tmb==0.0.4
|
tmb==0.0.4
|
||||||
|
|
||||||
# homeassistant.components.todoist
|
# homeassistant.components.todoist
|
||||||
todoist-python==8.0.0
|
todoist-api-python==2.0.2
|
||||||
|
|
||||||
# homeassistant.components.tolo
|
# homeassistant.components.tolo
|
||||||
tololib==0.1.0b3
|
tololib==0.1.0b3
|
||||||
|
@ -1702,7 +1702,7 @@ thermopro-ble==0.4.3
|
|||||||
tilt-ble==0.2.3
|
tilt-ble==0.2.3
|
||||||
|
|
||||||
# homeassistant.components.todoist
|
# homeassistant.components.todoist
|
||||||
todoist-python==8.0.0
|
todoist-api-python==2.0.2
|
||||||
|
|
||||||
# homeassistant.components.tolo
|
# homeassistant.components.tolo
|
||||||
tololib==0.1.0b3
|
tololib==0.1.0b3
|
||||||
|
@ -1,70 +1,70 @@
|
|||||||
"""Unit tests for the Todoist calendar platform."""
|
"""Unit tests for the Todoist calendar platform."""
|
||||||
from datetime import datetime
|
from unittest.mock import AsyncMock, patch
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from todoist_api_python.models import Due, Label, Project, Task
|
||||||
|
|
||||||
from homeassistant import setup
|
from homeassistant import setup
|
||||||
from homeassistant.components.todoist.calendar import DOMAIN, _parse_due_date
|
from homeassistant.components.todoist.calendar import DOMAIN
|
||||||
from homeassistant.components.todoist.types import DueDate
|
|
||||||
from homeassistant.const import CONF_TOKEN
|
from homeassistant.const import CONF_TOKEN
|
||||||
from homeassistant.helpers import entity_registry
|
from homeassistant.helpers import entity_registry
|
||||||
from homeassistant.util import dt
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_due_date_invalid():
|
@pytest.fixture(name="task")
|
||||||
"""Test None is returned if the due date can't be parsed."""
|
def mock_task() -> Task:
|
||||||
data: DueDate = {
|
"""Mock a todoist Task instance."""
|
||||||
"date": "invalid",
|
return Task(
|
||||||
"is_recurring": False,
|
assignee_id="1",
|
||||||
"lang": "en",
|
assigner_id="1",
|
||||||
"string": "",
|
comment_count=0,
|
||||||
"timezone": None,
|
is_completed=False,
|
||||||
}
|
content="A task",
|
||||||
assert _parse_due_date(data, timezone_offset=-8) is None
|
created_at="2021-10-01T00:00:00",
|
||||||
|
creator_id="1",
|
||||||
|
description="A task",
|
||||||
|
due=Due(is_recurring=False, date="2022-01-01", string="today"),
|
||||||
|
id="1",
|
||||||
|
labels=[],
|
||||||
|
order=1,
|
||||||
|
parent_id=None,
|
||||||
|
priority=1,
|
||||||
|
project_id="12345",
|
||||||
|
section_id=None,
|
||||||
|
url="https://todoist.com",
|
||||||
|
sync_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_due_date_with_no_time_data():
|
@pytest.fixture(name="api")
|
||||||
"""Test due date is parsed correctly when it has no time data."""
|
def mock_api() -> AsyncMock:
|
||||||
data: DueDate = {
|
|
||||||
"date": "2022-02-02",
|
|
||||||
"is_recurring": False,
|
|
||||||
"lang": "en",
|
|
||||||
"string": "Feb 2 2:00 PM",
|
|
||||||
"timezone": None,
|
|
||||||
}
|
|
||||||
actual = _parse_due_date(data, timezone_offset=-8)
|
|
||||||
assert datetime(2022, 2, 2, 8, 0, 0, tzinfo=dt.UTC) == actual
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_due_date_without_timezone_uses_offset():
|
|
||||||
"""Test due date uses user local timezone offset when it has no timezone."""
|
|
||||||
data: DueDate = {
|
|
||||||
"date": "2022-02-02T14:00:00",
|
|
||||||
"is_recurring": False,
|
|
||||||
"lang": "en",
|
|
||||||
"string": "Feb 2 2:00 PM",
|
|
||||||
"timezone": None,
|
|
||||||
}
|
|
||||||
actual = _parse_due_date(data, timezone_offset=-8)
|
|
||||||
assert datetime(2022, 2, 2, 22, 0, 0, tzinfo=dt.UTC) == actual
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="state")
|
|
||||||
def mock_state() -> dict[str, Any]:
|
|
||||||
"""Mock the api state."""
|
"""Mock the api state."""
|
||||||
return {
|
api = AsyncMock()
|
||||||
"collaborators": [],
|
api.get_projects.return_value = [
|
||||||
"labels": [{"name": "label1", "id": 1}],
|
Project(
|
||||||
"projects": [{"id": "12345", "name": "Name"}],
|
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 = []
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
@patch("homeassistant.components.todoist.calendar.TodoistAPI")
|
@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync")
|
||||||
async def test_calendar_entity_unique_id(todoist_api, hass, state):
|
async def test_calendar_entity_unique_id(todoist_api, hass, api):
|
||||||
"""Test unique id is set to project id."""
|
"""Test unique id is set to project id."""
|
||||||
api = Mock(state=state)
|
|
||||||
todoist_api.return_value = api
|
todoist_api.return_value = api
|
||||||
assert await setup.async_setup_component(
|
assert await setup.async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
@ -83,10 +83,9 @@ async def test_calendar_entity_unique_id(todoist_api, hass, state):
|
|||||||
assert "12345" == entity.unique_id
|
assert "12345" == entity.unique_id
|
||||||
|
|
||||||
|
|
||||||
@patch("homeassistant.components.todoist.calendar.TodoistAPI")
|
@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync")
|
||||||
async def test_calendar_custom_project_unique_id(todoist_api, hass, state):
|
async def test_calendar_custom_project_unique_id(todoist_api, hass, api):
|
||||||
"""Test unique id is None for any custom projects."""
|
"""Test unique id is None for any custom projects."""
|
||||||
api = Mock(state=state)
|
|
||||||
todoist_api.return_value = api
|
todoist_api.return_value = api
|
||||||
assert await setup.async_setup_component(
|
assert await setup.async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user