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:
Aaron Godfrey 2022-12-30 09:49:35 -08:00 committed by GitHub
parent 7440c34901
commit 8cbbdf21f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 209 additions and 208 deletions

View File

@ -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:

View File

@ -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"]

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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,