1 """Support for Todoist task management (https://todoist.com)."""
3 from __future__
import annotations
5 from datetime
import date, datetime, timedelta
10 from todoist_api_python.api_async
import TodoistAPIAsync
11 from todoist_api_python.endpoints
import get_sync_url
12 from todoist_api_python.headers
import create_headers
13 from todoist_api_python.models
import Due, Label, Task
14 import voluptuous
as vol
17 PLATFORM_SCHEMA
as CALENDAR_PLATFORM_SCHEMA,
38 CONF_PROJECT_DUE_DATE,
39 CONF_PROJECT_LABEL_WHITELIST,
40 CONF_PROJECT_WHITELIST,
62 from .coordinator
import TodoistCoordinator
63 from .types
import CalData, CustomProject, ProjectData, TodoistEvent
65 _LOGGER = logging.getLogger(__name__)
67 NEW_TASK_SERVICE_SCHEMA = vol.Schema(
69 vol.Required(CONTENT): cv.string,
70 vol.Optional(DESCRIPTION): cv.string,
71 vol.Optional(PROJECT_NAME, default=
"inbox"): vol.All(cv.string, vol.Lower),
72 vol.Optional(SECTION_NAME): vol.All(cv.string, vol.Lower),
73 vol.Optional(LABELS): cv.ensure_list_csv,
74 vol.Optional(ASSIGNEE): cv.string,
75 vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
76 vol.Exclusive(DUE_DATE_STRING,
"due_date"): cv.string,
77 vol.Optional(DUE_DATE_LANG): vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)),
78 vol.Exclusive(DUE_DATE,
"due_date"): cv.string,
79 vol.Exclusive(REMINDER_DATE_STRING,
"reminder_date"): cv.string,
80 vol.Optional(REMINDER_DATE_LANG): vol.All(
81 cv.string, vol.In(DUE_DATE_VALID_LANGS)
83 vol.Exclusive(REMINDER_DATE,
"reminder_date"): cv.string,
87 PLATFORM_SCHEMA = CALENDAR_PLATFORM_SCHEMA.extend(
89 vol.Required(CONF_TOKEN): cv.string,
90 vol.Optional(CONF_EXTRA_PROJECTS, default=[]): vol.All(
96 vol.Required(CONF_NAME): cv.string,
97 vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int),
98 vol.Optional(CONF_PROJECT_WHITELIST, default=[]): vol.All(
99 cv.ensure_list, [vol.All(cv.string, vol.Lower)]
102 CONF_PROJECT_LABEL_WHITELIST, default=[]
103 ): vol.All(cv.ensure_list, [vol.All(cv.string)]),
116 hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
118 """Set up the Todoist calendar platform config entry."""
119 coordinator = hass.data[DOMAIN][entry.entry_id]
120 projects = await coordinator.async_get_projects()
121 labels = await coordinator.async_get_labels()
124 for project
in projects:
125 project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id}
135 async_add_entities: AddEntitiesCallback,
136 discovery_info: DiscoveryInfoType |
None =
None,
138 """Set up the Todoist platform."""
139 token = config[CONF_TOKEN]
142 project_id_lookup = {}
144 api = TodoistAPIAsync(token)
146 await coordinator.async_refresh()
148 async
def _shutdown_coordinator(_: Event) ->
None:
149 await coordinator.async_shutdown()
151 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_coordinator)
155 projects = await api.get_projects()
158 labels = await api.get_labels()
162 for project
in projects:
165 project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id}
168 project_id_lookup[project.name.lower()] = project.id
171 extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS]
172 for extra_project
in extra_projects:
174 project_due_date = extra_project.get(CONF_PROJECT_DUE_DATE)
177 project_label_filter = extra_project[CONF_PROJECT_LABEL_WHITELIST]
181 project_name_filter = extra_project[CONF_PROJECT_WHITELIST]
182 project_id_filter: list[str] |
None =
None
183 if project_name_filter
is not None:
184 project_id_filter = [
185 project_id_lookup[project_name.lower()]
186 for project_name
in project_name_filter
190 project_devices.append(
193 {
"id":
None,
"name": extra_project[
"name"]},
195 due_date_days=project_due_date,
196 whitelisted_labels=project_label_filter,
197 whitelisted_projects=project_id_filter,
207 hass: HomeAssistant, coordinator: TodoistCoordinator
209 """Register services."""
211 if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK):
216 async
def handle_new_task(call: ServiceCall) ->
None:
217 """Call when a user creates a new Todoist Task from Home Assistant."""
218 project_name = call.data[PROJECT_NAME]
219 projects = await coordinator.async_get_projects()
220 project_id: str |
None =
None
221 for project
in projects:
222 if project_name == project.name.lower():
223 project_id = project.id
225 if project_id
is None:
227 translation_domain=DOMAIN,
228 translation_key=
"project_invalid",
229 translation_placeholders={
230 "project": project_name,
235 section_id: str |
None =
None
236 if SECTION_NAME
in call.data:
237 section_name = call.data[SECTION_NAME]
238 sections = await coordinator.async_get_sections(project_id)
239 for section
in sections:
240 if section_name == section.name.lower():
241 section_id = section.id
243 if section_id
is None:
245 translation_domain=DOMAIN,
246 translation_key=
"section_invalid",
247 translation_placeholders={
248 "section": section_name,
249 "project": project_name,
254 content = call.data[CONTENT]
255 data: dict[str, Any] = {
"project_id": project_id}
257 if description := call.data.get(DESCRIPTION):
258 data[
"description"] = description
260 if section_id
is not None:
261 data[
"section_id"] = section_id
263 if task_labels := call.data.get(LABELS):
264 data[
"labels"] = task_labels
266 if ASSIGNEE
in call.data:
267 collaborators = await coordinator.api.get_collaborators(project_id)
268 collaborator_id_lookup = {
269 collab.name.lower(): collab.id
for collab
in collaborators
271 task_assignee = call.data[ASSIGNEE].lower()
272 if task_assignee
in collaborator_id_lookup:
273 data[
"assignee_id"] = collaborator_id_lookup[task_assignee]
276 f
"User is not part of the shared project. user: {task_assignee}"
279 if PRIORITY
in call.data:
280 data[
"priority"] = call.data[PRIORITY]
282 if DUE_DATE_STRING
in call.data:
283 data[
"due_string"] = call.data[DUE_DATE_STRING]
285 if DUE_DATE_LANG
in call.data:
286 data[
"due_lang"] = call.data[DUE_DATE_LANG]
288 if DUE_DATE
in call.data:
289 due_date = dt_util.parse_datetime(call.data[DUE_DATE])
291 due = dt_util.parse_date(call.data[DUE_DATE])
293 raise ValueError(f
"Invalid due_date: {call.data[DUE_DATE]}")
294 due_date =
datetime(due.year, due.month, due.day)
296 due_date = dt_util.as_utc(due_date)
297 date_format =
"%Y-%m-%dT%H:%M:%S"
298 data[
"due_datetime"] = datetime.strftime(due_date, date_format)
300 api_task = await coordinator.api.add_task(content, **data)
305 sync_url = get_sync_url(
"sync")
306 _reminder_due: dict = {}
307 if REMINDER_DATE_STRING
in call.data:
308 _reminder_due[
"string"] = call.data[REMINDER_DATE_STRING]
310 if REMINDER_DATE_LANG
in call.data:
311 _reminder_due[
"lang"] = call.data[REMINDER_DATE_LANG]
313 if REMINDER_DATE
in call.data:
314 due_date = dt_util.parse_datetime(call.data[REMINDER_DATE])
316 due = dt_util.parse_date(call.data[REMINDER_DATE])
319 f
"Invalid reminder_date: {call.data[REMINDER_DATE]}"
321 due_date =
datetime(due.year, due.month, due.day)
323 due_date = dt_util.as_utc(due_date)
324 date_format =
"%Y-%m-%dT%H:%M:%S"
325 _reminder_due[
"date"] = datetime.strftime(due_date, date_format)
327 async
def add_reminder(reminder_due: dict):
331 "type":
"reminder_add",
332 "temp_id":
str(uuid.uuid1()),
333 "uuid":
str(uuid.uuid1()),
335 "item_id": api_task.id,
342 headers = create_headers(token=coordinator.token, with_content=
True)
343 return await session.post(sync_url, headers=headers, json=reminder_data)
346 await add_reminder(_reminder_due)
348 _LOGGER.debug(
"Created Todoist task: %s", call.data[CONTENT])
350 hass.services.async_register(
351 DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA
356 """A device for getting the next Task from a Todoist Project."""
360 coordinator: TodoistCoordinator,
363 due_date_days: int |
None =
None,
364 whitelisted_labels: list[str] |
None =
None,
365 whitelisted_projects: list[str] |
None =
None,
367 """Create the Todoist Calendar Entity."""
368 super().
__init__(coordinator=coordinator)
373 due_date_days=due_date_days,
374 whitelisted_labels=whitelisted_labels,
375 whitelisted_projects=whitelisted_projects,
377 self._cal_data: CalData = {}
380 str(data[CONF_ID])
if data.get(CONF_ID)
is not None else None
385 """Handle updated data from the coordinator."""
390 def event(self) -> CalendarEvent | None:
391 """Return the next upcoming event."""
392 return self.
datadatadata.calendar_event
396 """Return the name of the entity."""
397 return self.
_name_name
400 """Update all Todoist Calendars."""
407 start_date: datetime,
409 ) -> list[CalendarEvent]:
410 """Get all events in a specific time frame."""
415 """Return the device state attributes."""
421 DUE_TODAY: self.
datadatadata.event[DUE_TODAY],
422 OVERDUE: self.
datadatadata.event[OVERDUE],
423 ALL_TASKS: [task[SUMMARY]
for task
in self.
datadatadata.all_project_tasks],
424 PRIORITY: self.
datadatadata.event[PRIORITY],
425 LABELS: self.
datadatadata.event[LABELS],
430 """Class used by the Task Entity service object to hold all Todoist Tasks.
432 This is analogous to the GoogleCalendarData found in the Google Calendar
435 Takes an object with a 'name' field and optionally an 'id' field (either
436 user-defined or from the Todoist API), a Todoist API token, and an optional
437 integer specifying the latest number of days from now a task can be due (7
438 means everything due in the next week, 0 means today, etc.).
440 This object has an exposed 'event' property (used by the Calendar platform
441 to determine the next calendar event) and an exposed 'update' method (used
442 by the Calendar platform to poll for new calendar events).
444 The 'event' is a representation of a Todoist Task, with defined parameters
445 of 'due_today' (is the task due today?), 'all_day' (does the task have a
446 due date?), 'task_labels' (all labels assigned to the task), 'message'
447 (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing
448 to the task on the Todoist website), 'end_time' (what time the event is
449 due), 'start_time' (what time this event was last updated), 'overdue' (is
450 the task past its due date?), 'priority' (1-4, how important the task is,
451 with 4 being the most important), and 'all_tasks' (all tasks in this
452 project, sorted by how important they are).
454 'offset_reached', 'location', and 'friendly_name' are defined by the
455 platform itself, but are not used by this component at all.
457 The 'update' method polls the Todoist API for new projects/tasks, as well
458 as any updates to current projects/tasks. This occurs every SCAN_INTERVAL minutes.
463 project_data: ProjectData,
465 coordinator: TodoistCoordinator,
466 due_date_days: int |
None =
None,
467 whitelisted_labels: list[str] |
None =
None,
468 whitelisted_projects: list[str] |
None =
None,
470 """Initialize a Todoist Project."""
471 self.
eventevent: TodoistEvent |
None =
None
474 self.
_name_name = project_data[CONF_NAME]
476 self.
_id_id = project_data.get(CONF_ID)
482 self.all_project_tasks: list[TodoistEvent] = []
487 if due_date_days
is not None:
492 if whitelisted_labels
is not None:
497 if whitelisted_projects
is not None:
502 """Return the next upcoming calendar event."""
503 if not self.
eventevent:
506 start = self.
eventevent[START]
507 if self.
eventevent.
get(ALL_DAY)
or self.
eventevent[END]
is None:
509 summary=self.
eventevent[SUMMARY],
515 summary=self.
eventevent[SUMMARY], start=start, end=self.
eventevent[END]
519 """Create a dictionary based on a Task passed from the Todoist API.
521 Will return 'None' if the task is to be filtered out.
523 task: TodoistEvent = {
525 COMPLETED: data.is_completed,
526 DESCRIPTION: f
"https://todoist.com/showTask?id={data.id}",
531 PRIORITY: data.priority,
532 START: dt_util.now(),
533 SUMMARY: data.content,
545 label.name
for label
in self.
_labels_labels
if label.name
in data.labels
548 not any(label
in task[LABELS]
for label
in self.
_label_whitelist_label_whitelist)
559 if data.due
is not None:
560 end = dt_util.parse_datetime(
561 data.due.datetime
if data.due.datetime
else data.due.date
563 task[END] = dt_util.as_local(end)
if end
is not None else end
564 if task[END]
is not None:
572 task[DUE_TODAY] = task[END].
date() == dt_util.now().
date()
575 if task[END] <= task[START]:
580 task[END] = task[START] +
timedelta(hours=1)
582 task[OVERDUE] =
False
592 task[DUE_TODAY] =
False
593 task[OVERDUE] =
False
600 """Search through a list of events for the "best" event to select.
602 The "best" event is determined by the following criteria:
603 * A proposed event must not be completed
604 * A proposed event must have an end date (otherwise we go with
605 the event at index 0, selected above)
606 * A proposed event must be on the same day or earlier as our
608 * If a proposed event is an earlier day than what we have so
610 * If a proposed event is on the same day as our current event
611 and the proposed event has a higher priority than our current
613 * If a proposed event is on the same day as our current event,
614 has the same priority as our current event, but is due earlier
615 in the day, select it
620 event = project_tasks[-1]
622 for proposed_event
in project_tasks:
623 if event == proposed_event:
626 if proposed_event[COMPLETED]:
630 if proposed_event[END]
is None:
632 if event[END]
is None and (proposed_event[PRIORITY] < event[PRIORITY]):
635 event = proposed_event
638 if event[END]
is None:
640 event = proposed_event
643 if proposed_event[END].
date() > event[END].
date():
647 if proposed_event[END].
date() < event[END].
date():
649 event = proposed_event
652 if proposed_event[PRIORITY] > event[PRIORITY]:
654 event = proposed_event
657 if proposed_event[PRIORITY] == event[PRIORITY]
and (
658 event[END]
is not None and proposed_event[END] < event[END]
660 event = proposed_event
665 self, start_date: datetime, end_date: datetime
666 ) -> list[CalendarEvent]:
667 """Get all tasks in a specific time frame."""
669 if self.
_id_id
is None:
670 project_task_data = [
674 project_task_data = [task
for task
in tasks
if task.project_id == self.
_id_id]
677 for task
in project_task_data:
684 summary=task.content,
688 if event.start_datetime_local >= end_date:
690 if event.end_datetime_local < start_date:
696 """Get the latest data."""
698 if self.
_id_id
is None:
699 project_task_data = [
706 project_task_data = [task
for task
in tasks
if task.project_id == self.
_id_id]
709 if not project_task_data:
710 _LOGGER.debug(
"No data for %s", self.
_name_name)
716 for task
in project_task_data:
718 if todoist_task
is not None:
720 project_tasks.append(todoist_task)
722 if not project_tasks:
724 _LOGGER.debug(
"No valid tasks for %s", self.
_name_name)
725 self.
eventevent =
None
730 self.all_project_tasks.clear()
736 _LOGGER.debug(
"Found Todoist Task: %s", best_task[SUMMARY])
737 project_tasks.remove(best_task)
738 self.all_project_tasks.append(best_task)
740 event = self.all_project_tasks[0]
741 if event
is None or event[START]
is None:
742 _LOGGER.debug(
"No valid event or event start for %s", self.
_name_name)
743 self.
eventevent =
None
745 self.
eventevent = event
746 _LOGGER.debug(
"Updated %s", self.
_name_name)
750 """Return the task due date as a start date or date time."""
752 start = dt_util.parse_datetime(due.datetime)
755 return dt_util.as_local(start)
757 return dt_util.parse_date(due.date)
None __init__(self, ProjectData project_data, list[Label] labels, TodoistCoordinator coordinator, int|None due_date_days=None, list[str]|None whitelisted_labels=None, list[str]|None whitelisted_projects=None)
TodoistEvent select_best_task(list[TodoistEvent] project_tasks)
list[CalendarEvent] async_get_events(self, datetime start_date, datetime end_date)
def create_todoist_task(self, Task data)
CalendarEvent|None calendar_event(self)
None _handle_coordinator_update(self)
list[CalendarEvent] async_get_events(self, HomeAssistant hass, datetime start_date, datetime end_date)
CalendarEvent|None event(self)
None __init__(self, TodoistCoordinator coordinator, ProjectData data, list[Label] labels, int|None due_date_days=None, list[str]|None whitelisted_labels=None, list[str]|None whitelisted_projects=None)
dict[str, Any]|None extra_state_attributes(self)
web.Response get(self, web.Request request, str config_key)
IssData update(pyiss.ISS iss)
datetime|date|None get_start(Due due)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
None async_register_services(HomeAssistant hass, TodoistCoordinator coordinator)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
datetime_sys datetime(Any value)