diff --git a/.coveragerc b/.coveragerc index d5eb32e670c..2b96400d1e6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -247,6 +247,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640..5198381b976 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ import re from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000..952e2302091 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000..ae9a1c9afa8 --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + 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. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analagous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # 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[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/requirements_all.txt b/requirements_all.txt index 4473afdf37a..543adbd4f6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -961,6 +961,9 @@ thingspeak==0.4.1 # homeassistant.components.light.tikteck tikteck==0.4 +# homeassistant.components.calendar.todoist +todoist-python==7.0.17 + # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.11