Add support for Todoist platform#9236
Conversation
Creating a new platform for Todoist - https://todoist.com
This means you can now specify things such as 'all tasks due today', 'all tasks due this week', etc.
MartinHjelmare
left a comment
There was a problem hiding this comment.
I think you should use a pypi package for the todoist specific logic.
| return attributes | ||
|
|
||
|
|
||
| class TodoistProjectData(object): |
There was a problem hiding this comment.
This should probably be broken out into a python package and published on pypi. Is there no pypi package already for todoist?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
The service schema should validate call.data to make this error impossible.
This is left over from before we validated the inputs using the service schema.
This also removes functionality regarding finding out how many comments a task has. This functionality may be added back in the future.
|
@MartinHjelmare I've made the changes you requested.
Let me know if you need any more changes! |
| import logging | ||
| from datetime import datetime | ||
| from datetime import timedelta | ||
| import os |
There was a problem hiding this comment.
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). | |||
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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']: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Default to an empty list.
| # on the whitelist. | ||
| if self._label_whitelist is not None: | ||
| found_label = False | ||
| for label in self._label_whitelist: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
event = project_tasks[-1]|
Alright, I believe all of your changes should be good to go! Let me know if you have any more! |
MartinHjelmare
left a comment
There was a problem hiding this comment.
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']) |
There was a problem hiding this comment.
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']) |
| timedelta(days=1) | ||
| ).strftime(DATE_STR_FORMAT) | ||
| } | ||
| _LOGGER.debug("Updated " + self._name + ".") |
There was a problem hiding this comment.
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']) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 NoneThere was a problem hiding this comment.
@MartinHjelmare
So, here's the results with if not all:
And here is 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 |
|
@MartinHjelmare Your changes should be live now. |
|
@Jay2645 see my comment here #9236 (comment). Let me know if I'm misunderstanding the whitelist. |
|
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 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. |
MartinHjelmare
left a comment
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
Don't check the length explicitly, just:
if ... and self._label_whitelist:An empty whitelist will evaluate to False.
There was a problem hiding this comment.
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.


Description:
Support for Todoist task management (https://todoist.com).
Todoist breaks task management down into 2 structures:
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 --onif you have a task in it that's not marked as completed, oroffif 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
.pyfile. 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):Checklist:
If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
toxrun successfully. Your PR cannot be merged unless tests passREQUIREMENTSvariable (example).requirements_all.txtby runningscript/gen_requirements_all.py..coveragerc.If the code does not interact with devices:Local tests withtoxrun successfully. Your PR cannot be merged unless tests passTests have been added to verify that the new code works.