Skip to content

Add support for Todoist platform#9236

Merged
MartinHjelmare merged 42 commits into
home-assistant:devfrom
Jay2645:add-todoist
Sep 14, 2017
Merged

Add support for Todoist platform#9236
MartinHjelmare merged 42 commits into
home-assistant:devfrom
Jay2645:add-todoist

Conversation

@Jay2645
Copy link
Copy Markdown
Contributor

@Jay2645 Jay2645 commented Aug 31, 2017

Description:

Support for Todoist task management (https://todoist.com).

Todoist breaks task management down into 2 structures:

  • Tasks, which are "things" you can do and can mark as being done
  • Projects, which are essentially containers for tasks.

In Home Assistant, each project gets its own calendar. The start date of the calendar is set to now, while the end date is set to the due date -- the logic for this being that most tasks you set on here are tasks you could theoretically do now. There are some exceptions (like taking someone to the airport), but a good percentage of the things on your to-do list could be done right now if you wanted to. Because of that, the calendar takes a simple state -- on if you have a task in it that's not marked as completed, or off if you have no tasks at all in that project.

The calendar keeps track of what your most "important" task in that project is, governed by a number of factors specified in the header of the .py file. You can access all the tasks in a project via a template if you wanted to, and they'd all be sorted from most important to least important.

Pull request in home-assistant.github.io with documentation (if applicable): home-assistant/home-assistant.io#3283

Example entry for configuration.yaml (if applicable):

calendar:
  - platform: todoist
    token: !secret todoist_token
    custom_projects:
      - name: 'All Projects'
      - name: 'Due Today'
        due_date_days: 0
      - name: 'Due This Week'
        due_date_days: 7
      - name: 'Math Homework'
        labels:
          - Homework
        include_projects:
          - Mathematical Structures II
          - Calculus II

Checklist:

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • Local tests with tox run successfully. Your PR cannot be merged unless tests pass
  • New dependencies have been added to the REQUIREMENTS variable (example).
  • New dependencies are only imported inside functions that use them (example).
  • New dependencies have been added to requirements_all.txt by running script/gen_requirements_all.py.
  • New files were added to .coveragerc.

If the code does not interact with devices:

  • Local tests with tox run successfully. Your PR cannot be merged unless tests pass
  • Tests have been added to verify that the new code works.

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should use a pypi package for the todoist specific logic.

return attributes


class TodoistProjectData(object):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be broken out into a python package and published on pypi. Is there no pypi package already for todoist?

Copy link
Copy Markdown
Contributor Author

@Jay2645 Jay2645 Sep 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is, but I didn't want to introduce any dependencies. When I first started designing the platform, I had the misconception after looking at their Python API that I would need to do everything via OAuth, which is obviously more of a challenge than just having the user specify a token in their config. Looking back at it now, I see that there is actually a way to do things with a token, but I'm still adverse to introducing extra dependencies.

That being said, I would still need some Todoist logic somewhere. This class has the scoring algorithm, which could be moved to the actual device. But (more importantly), it also supports "custom" projects -- i.e., where the user can ask to grab everything in the next week, or all things tagged "Homework", or what have you. That stuff comes from the config file, and it'll all have to be kept track of locally (otherwise users may wind up accidentally creating a new project as soon as the API syncs, from what I've gathered in a cursory examination).

This class also "converts" the data from Todoist to something that Home Assistant can process easier -- as an example, the due date logic around line 400 or so. Sure, I'd be able to get an actual datetime object from the API instead of relying upon strptime, but I would still need to "translate" that to the end time for a Calendar in Home Assistant. This also ensures that every single task has a start and end date, that there's always a list of comments (even if it's empty), etc. By default, if something is empty, there's just no entry for it in the API. The idea is to do as much logic as makes sense here, in order to cut down on the amount of logic contained within the actual device. I can probably move the logic for determining if something is due today or overdue into the device, but at the time it seemed fitting just to do it here, since I was already working with the data.

The end result is I would need some class which looks vaguely like this, except I would have references to Todoist's PyPI API calls instead of using requests. I'd still need to "score" each task, since Todoist sorts by date added, not by actual priority. I'd still need to "translate" a lot of the Todoist-specific things to what the Calendar device expects, but perhaps this can be done on the device end. Most importantly, though, I'd still need to keep a list of "local" projects for those user-defined devices that can be specified in the config. That'd be where most of the logic would come from.

I can do it, if you'd like me to. Doing so would give me access to Todoist Reminders, so I could probably expose the Reminder functionality built-in to Todoist to Home Assistant (right now, I can't access Reminders via the REST API -- just Tasks and their due dates). It would be a fairly easy change overall, but I'm not sure whether it would cut down on the amount of platform-specific logic unless I got rid of the custom user-defined projects (which is where a lot of the logic comes from currently). It would also introduce a new dependency, of course.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introducing a new dependency is not a problem, but rather part of the ground rules for home assistant:
https://home-assistant.io/developers/code_review_platform/#6-communication-with-devicesservices
https://home-assistant.io/developers/code_review_platform/#1-requirements

Having an interface class or callback function between the pypi package API and the home assistant entity API is ok, but we try to minimize it as much as possible. Usually this class/function is responsible for fetching/accepting new data into home assistant.

CONF_PROJECT_WHITELIST = 'include_projects'
CONF_PROJECT_LABEL_WHITELIST = 'labels'

CONFIG_SCHEMA = vol.Schema({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Platforms should implement PLATFORM_SCHEMA.

task_data['due_datetime'] = call.data['due_datetime']
elif 'due_date' in call.data:
task_data['due_date'] = call.data['due_date']
except KeyError:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service schema should validate call.data to make this error impossible.

@Jay2645
Copy link
Copy Markdown
Contributor Author

Jay2645 commented Sep 8, 2017

@MartinHjelmare I've made the changes you requested.

  • I changed from implementing CONFIG_SCHEMA to implementing PLATFORM_SCHEMA; I'm not 100% sure I'm doing things right, but it seems to work in Home Assistant.

  • call.data was already being validated; the try-catch block you pointed out was from an older version before I implemented validation. I went ahead and just removed it.

  • In the end, I wasn't able to get rid of the TodoistProjectData interface class; I forgot that the Calendar platform itself requires an interface class with an update function and an event object, both of which get defined in the TodoistProjectData interface class. This pattern is seen in the Demo Calendar component as well as the Google Calendar component. I did, however, move to the Todoist PyPI package and added the dependencies.

Let me know if you need any more changes!

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comments are mostly about three themes:

  • Use the schema validation as much as possible to get good default values and simplify later code.
  • Optimize checks and iterations using (list) comprehension.
  • Only log at debug level.

import logging
from datetime import datetime
from datetime import timedelta
import os
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sort the import lines alphabetically within each group builtin, 3rd party and homeassistant.

@@ -0,0 +1,595 @@
"""
Support for Todoist task management (https://todoist.com).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put all of this info in the docs PR, if not there already. Look at another platform module for an example how to write the docstring. It should contain a link to the docs page.

})

# Your Todoist API token.
# Find yours at https://todoist.com/Users/viewPrefs?page=authorizations.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this belongs in the docs.

"""Setup the Todoist platform."""
# Check token:
token = config.get(CONF_API_TOKEN)
if token is None or not isinstance(token, str):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this. It's already checked in the config validation.


# Look up IDs based on (lowercase) names.
project_id_lookup = {} # pylint: disable=C0103
label_id_lookup = {} # pylint: disable=C0103
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like these two dicts are only used within setup_platform. Move them inside setup_platform.

# All task Labels (optional parameter).
task['labels'] = []
if len(data['labels']) > 0:
for label_id in data['labels']:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify this using a list comprehension:

task['labels'] = [
    label['name'] for label in self._labels
    if label['id'] in data['labels']]

I suggest you also make the labels lowercase here already, see comment below.

if whitelisted_labels is not None:
self._label_whitelist = whitelisted_labels
else:
self._label_whitelist = None
Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare Sep 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default to an empty list.

# on the whitelist.
if self._label_whitelist is not None:
found_label = False
for label in self._label_whitelist:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make the task labels lowercase above you don't need to do that here and you can just:

if not any(label in task['labels'] for label in self._label_whitelist):
    return None

has the same priority as our current event, but is due earlier
in the day, select it
"""
if len(project_tasks) > 0:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already make this check before you call this method. I suggest you assume you get a list and just iterate over that.

# Start at the end of the list, so if tasks don't have a due date
# the newest ones are the most important.

event = project_tasks[len(project_tasks) - 1]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event = project_tasks[-1]

@Jay2645
Copy link
Copy Markdown
Contributor Author

Jay2645 commented Sep 11, 2017

Alright, I believe all of your changes should be good to go! Let me know if you have any more!

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work! Some comments below. An optional improvement would be to define string constants at the top of the module for the magic strings, eg:

PROJECTS = 'projects'
LABELS = 'labels'

and use those where needed in the code. It will make typo risk less, look cleaner, and be easier to change a string in the future.

item.update(due_date_utc=due_date)
# Commit changes
api.commit()
_LOGGER.debug("Created Todoist task: " + call.data['content'])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't concatenate strings like this. For logging use the old string formatting syntax:

_LOGGER.debug("Created Todoist task: %s", call.data['content'])

# they have, organized)
while len(project_tasks) > 0:
best_task = self.select_best_task(project_tasks)
_LOGGER.debug("Found Todoist Task: " + best_task['summary'])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String formatting.

timedelta(days=1)
).strftime(DATE_STR_FORMAT)
}
_LOGGER.debug("Updated " + self._name + ".")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String formatting. We don't end logging messages with period.

task['summary'] = data['content']
task['completed'] = data['checked'] == 1
task['priority'] = data['priority']
task_url = 'https://todoist.com/showTask?id={}'.format(data['id'])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why cache this in the task_url variable if you don't use that anywhere else? Just save the string directly in task['description'].

task['description'] = 'https://todoist.com/showTask?id={}'.format(
    data['id'])

if label['id'] in data['labels']]

whitelist = self._label_whitelist
if any(label in task['labels'] for label in whitelist):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be:

if not any(
        label in task['labels'] for label in self._label_whitelist):

Otherwise it will have the opposite effect of what you had originally I think.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had tried that (you had suggested that change last time), and for whatever reason everything was inverted. I'm not exactly sure why it works, but it works.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this again. Shouldn't all labels in task[LABELS] be on the whitelist to be ok? Otherwise we should return None. Then I think it should be:

if not all(label in self._label_whitelist) for label in task[LABELS]:
    return None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartinHjelmare
So, here's the results with if not all:

if not all

And here is if any:

if any

if any gives the correct results (with the label whitelist working as an or statement, as documented here).

if not all(label for self._label_whitelist) for label in task[LABELS] causes any task with a label to be rejected for some reason. You can see that in the first screenshot -- the project "Mathematical Structures II only consists of tasks with the label "Homework" or "School" (or both), so calendar.mathematical_structures_ii rejects all of them. This change propagates down to my "custom" calendars specified in my configuration: calendar.math_homework and calendar.due_today are only taking into account tasks which don't have labels. You can see calendar.movies_to_watch is not affected, as none of the tasks there have labels.

The end result is only tasks with exactly 0 labels are accepted, with the whitelist not being taken into account at all.

if any(label in task[LABELS] for label in self._label_whitelist) returns the correct values, but I'm not sure why. I agree with you that there should be a not in there somewhere, but a not gives me inverted inputs.

I'll take a look at what's going on exactly and see if I can fix it. There must be some logic error somewhere. Maybe if any(label in task[LABELS] for label in self._label_whitelist) is just never returning true, and thus never tossing out anything?

label['name'] for label in self._labels
if label['id'] in data['labels']]

whitelist = self._label_whitelist
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed caching.

@Jay2645
Copy link
Copy Markdown
Contributor Author

Jay2645 commented Sep 12, 2017

@MartinHjelmare Your changes should be live now.

@MartinHjelmare
Copy link
Copy Markdown
Member

@Jay2645 see my comment here #9236 (comment). Let me know if I'm misunderstanding the whitelist.

@Jay2645
Copy link
Copy Markdown
Contributor Author

Jay2645 commented Sep 12, 2017

Fixed it. The whitelist is an or, not an and, (see here), but that wasn't the issue after all.

The issue is I was comparing a list of user-defined label names to a list of the names of labels Todoist reports as being part of the task. The names Todoist reported were all lowercase; the names the user defined could be uppercase or lowercase. It was comparing 'Homework' to 'homework', which (of course) is always false. To make matters worse, in your implementation if an empty list is compared against an empty list, it was returning true.

I moved to your implementation and simply made the user-defined labels lowercase. I also added a sanity check to make sure we're not comparing an empty list, and it seems to work fine now.

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last clean up, then this should be good to go.

if not any(label in task[LABELS]
for label in self._label_whitelist) and (
len(self._label_whitelist) > 0):
len(self._label_whitelist) > 0):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't check the length explicitly, just:

if ... and self._label_whitelist:

An empty whitelist will evaluate to False.

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare Sep 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even better actually would be to turn the expression around and check the whitelist first (true/false), then compare the whitelist labels with the task labels.

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@MartinHjelmare MartinHjelmare merged commit c94b3a7 into home-assistant:dev Sep 14, 2017
@balloob balloob mentioned this pull request Sep 22, 2017
@home-assistant home-assistant locked and limited conversation to collaborators Mar 3, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants