From 80a9539f97f63175041c7af871ed4807dc2e0a18 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Wed, 1 Nov 2017 04:33:47 +0100 Subject: [PATCH] integration with Remember The Milk. (#9803) * MVP integration with Remember The Milk. This version offers a service allowing you to create new issues in Remember The Milk. * fixed pylint issue with import path * - added files to .coveragerc as the server inerface is hard to test - added tests for config file handling * fixed lint error * added missing docstrings * removed stray edit * fixed minor issues reported by @fabaff * changed naming of the service, so that serveral accounts can be used * added disclaimer * moved service description to services.yaml * fixed blank lines * fixed structure of configuration * added comment about httplib2 * renamed internal config file * improved logging statements * moved entry in services.yaml into separate folder. Had to move the component itself as well. * fixed static analysis findings * mocked first test case * fixed bug in config handling, fixed unit tests * mocked second test case * fixed line length * fixed static analysis findings and failing test case * also renamed file in .coveragerc * control flow changes as requested by @balloob --- .coveragerc | 1 + .../components/remember_the_milk/__init__.py | 251 ++++++++++++++++++ .../remember_the_milk/services.yaml | 9 + requirements_all.txt | 6 + tests/components/test_remember_the_milk.py | 49 ++++ 5 files changed, 316 insertions(+) create mode 100644 homeassistant/components/remember_the_milk/__init__.py create mode 100644 homeassistant/components/remember_the_milk/services.yaml create mode 100644 tests/components/test_remember_the_milk.py diff --git a/.coveragerc b/.coveragerc index d706f09ae90..06263103990 100644 --- a/.coveragerc +++ b/.coveragerc @@ -457,6 +457,7 @@ omit = homeassistant/components/notify/yessssms.py homeassistant/components/nuimo_controller.py homeassistant/components/prometheus.py + homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py new file mode 100644 index 00000000000..4a788297c60 --- /dev/null +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -0,0 +1,251 @@ +"""Component to interact with Remember The Milk. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/remember_the_milk/ + +Minimum viable product, it currently only support creating new tasks in your +Remember The Milk (https://www.rememberthemilk.com/) account. + +This product uses the Remember The Milk API but is not endorsed or certified +by Remember The Milk. +""" +import logging +import os +import json +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, CONF_NAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +# httplib2 is a transitive dependency from RtmAPI. If this dependency is not +# set explicitly, the library does not work. +REQUIREMENTS = ['RtmAPI==0.7.0', 'httplib2==0.10.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'remember_the_milk' +DEFAULT_NAME = DOMAIN +GROUP_NAME_RTM = 'remember the milk accounts' + +CONF_SHARED_SECRET = 'shared_secret' + +RTM_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SHARED_SECRET): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA]) +}, extra=vol.ALLOW_EXTRA) + +CONFIG_FILE_NAME = '.remember_the_milk.conf' +SERVICE_CREATE_TASK = 'create_task' + +SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + + +def setup(hass, config): + """Set up the remember_the_milk component.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, + group_name=GROUP_NAME_RTM) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + stored_rtm_config = RememberTheMilkConfiguration(hass) + for rtm_config in config[DOMAIN]: + account_name = rtm_config[CONF_NAME] + _LOGGER.info("Adding Remember the milk account %s", account_name) + api_key = rtm_config[CONF_API_KEY] + shared_secret = rtm_config[CONF_SHARED_SECRET] + token = stored_rtm_config.get_token(account_name) + if token: + _LOGGER.debug("found token for account %s", account_name) + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component, descriptions) + else: + _register_new_account( + hass, account_name, api_key, shared_secret, + stored_rtm_config, component, descriptions) + + _LOGGER.debug("Finished adding all Remember the milk accounts") + return True + + +def _create_instance(hass, account_name, api_key, shared_secret, + token, stored_rtm_config, component, descriptions): + entity = RememberTheMilk(account_name, api_key, shared_secret, + token, stored_rtm_config) + component.add_entity(entity) + hass.services.async_register( + DOMAIN, '{}_create_task'.format(account_name), entity.create_task, + description=descriptions.get(SERVICE_CREATE_TASK), + schema=SERVICE_SCHEMA_CREATE_TASK) + + +def _register_new_account(hass, account_name, api_key, shared_secret, + stored_rtm_config, component, descriptions): + from rtmapi import Rtm + + request_id = None + configurator = hass.components.configurator + api = Rtm(api_key, shared_secret, "write", None) + url, frob = api.authenticate_desktop() + _LOGGER.debug('sent authentication request to server') + + def register_account_callback(_): + """Callback for configurator.""" + api.retrieve_token(frob) + token = api.token + if api.token is None: + _LOGGER.error('Failed to register, please try again.') + configurator.notify_errors( + request_id, + 'Failed to register, please try again.') + return + + stored_rtm_config.set_token(account_name, token) + _LOGGER.debug('retrieved new token from server') + + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component, descriptions) + + configurator.request_done(request_id) + + request_id = configurator.async_request_config( + '{} - {}'.format(DOMAIN, account_name), + callback=register_account_callback, + description='You need to log in to Remember The Milk to' + + 'connect your account. \n\n' + + 'Step 1: Click on the link "Remember The Milk login"\n\n' + + 'Step 2: Click on "login completed"', + link_name='Remember The Milk login', + link_url=url, + submit_caption="login completed", + ) + + +class RememberTheMilkConfiguration(object): + """Internal configuration data for RememberTheMilk class. + + This class stores the authentication token it get from the backend. + """ + + def __init__(self, hass): + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + if not os.path.isfile(self._config_file_path): + self._config = dict() + return + try: + _LOGGER.debug('loading configuration from file: %s', + self._config_file_path) + with open(self._config_file_path, 'r') as config_file: + self._config = json.load(config_file) + except ValueError: + _LOGGER.error('failed to load configuration file, creating a ' + 'new one: %s', self._config_file_path) + self._config = dict() + + def save_config(self): + """Write the configuration to a file.""" + with open(self._config_file_path, 'w') as config_file: + json.dump(self._config, config_file) + + def get_token(self, profile_name): + """Get the server token for a profile.""" + if profile_name in self._config: + return self._config[profile_name][CONF_TOKEN] + return None + + def set_token(self, profile_name, token): + """Store a new server token for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = dict() + self._config[profile_name][CONF_TOKEN] = token + self.save_config() + + def delete_token(self, profile_name): + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self.save_config() + + +class RememberTheMilk(Entity): + """MVP implementation of an interface to Remember The Milk.""" + + def __init__(self, name, api_key, shared_secret, token, rtm_config): + """Create new instance of Remember The Milk component.""" + import rtmapi + + self._name = name + self._api_key = api_key + self._shared_secret = shared_secret + self._token = token + self._rtm_config = rtm_config + self._rtm_api = rtmapi.Rtm(api_key, shared_secret, "delete", token) + self._token_valid = None + self._check_token() + _LOGGER.debug("instance created for account %s", self._name) + + def _check_token(self): + """Check if the API token is still valid. + + If it is not valid any more, delete it from the configuration. This + will trigger a new authentication process. + """ + valid = self._rtm_api.token_valid() + if not valid: + _LOGGER.error('Token for account %s is invalid. You need to ' + 'register again!', self.name) + self._rtm_config.delete_token(self._name) + self._token_valid = False + else: + self._token_valid = True + return self._token_valid + + def create_task(self, call): + """Create a new task on Remember The Milk. + + You can use the smart syntax to define the attribues of a new task, + e.g. "my task #some_tag ^today" will add tag "some_tag" and set the + due date to today. + """ + import rtmapi + + try: + task_name = call.data.get('name') + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + self._rtm_api.rtm.tasks.add( + timeline=timeline, name=task_name, parse='1') + _LOGGER.debug('created new task "%s" in account %s', + task_name, self.name) + except rtmapi.RtmRequestFailedException as rtm_exception: + _LOGGER.error('Error creating new Remember The Milk task for ' + 'account %s: %s', self._name, rtm_exception) + return False + return True + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if not self._token_valid: + return 'API token invalid' + return STATE_OK diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml new file mode 100644 index 00000000000..ebf242013f1 --- /dev/null +++ b/homeassistant/components/remember_the_milk/services.yaml @@ -0,0 +1,9 @@ +# Describes the format for available Remember The Milk services + +create_task: + description: Create a new task in your Remember The Milk account + + fields: + name: + description: name of the new task, you can use the smart syntax here + example: 'do this ^today #from_hass' \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index d503d8e88f6..f79c6c35f53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,6 +42,9 @@ PyXiaomiGateway==0.6.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 +# homeassistant.components.remember_the_milk +RtmAPI==0.7.0 + # homeassistant.components.media_player.sonos SoCo==0.12 @@ -332,6 +335,9 @@ home-assistant-frontend==20171030.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a +# homeassistant.components.remember_the_milk +httplib2==0.10.3 + # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 diff --git a/tests/components/test_remember_the_milk.py b/tests/components/test_remember_the_milk.py new file mode 100644 index 00000000000..b59c840d765 --- /dev/null +++ b/tests/components/test_remember_the_milk.py @@ -0,0 +1,49 @@ +"""Tests for the Remember The Milk component.""" + +import logging +import unittest +from unittest.mock import patch, mock_open, Mock + +import homeassistant.components.remember_the_milk as rtm + +from tests.common import get_test_home_assistant + +_LOGGER = logging.getLogger(__name__) + + +class TestConfiguration(unittest.TestCase): + """Basic tests for the class RememberTheMilkConfiguration.""" + + def setUp(self): + """Set up test home assistant main loop.""" + self.hass = get_test_home_assistant() + self.profile = "myprofile" + self.token = "mytoken" + self.json_string = '{"myprofile": {"token": "mytoken"}}' + + def tearDown(self): + """Exit home assistant.""" + self.hass.stop() + + def test_create_new(self): + """Test creating a new config file.""" + with patch("builtins.open", mock_open()), \ + patch("os.path.isfile", Mock(return_value=False)): + config = rtm.RememberTheMilkConfiguration(self.hass) + config.set_token(self.profile, self.token) + self.assertEqual(config.get_token(self.profile), self.token) + + def test_load_config(self): + """Test loading an existing token from the file.""" + with patch("builtins.open", mock_open(read_data=self.json_string)), \ + patch("os.path.isfile", Mock(return_value=True)): + config = rtm.RememberTheMilkConfiguration(self.hass) + self.assertEqual(config.get_token(self.profile), self.token) + + def test_invalid_data(self): + """Test starts with invalid data and should not raise an exception.""" + with patch("builtins.open", + mock_open(read_data='random charachters')),\ + patch("os.path.isfile", Mock(return_value=True)): + config = rtm.RememberTheMilkConfiguration(self.hass) + self.assertIsNotNone(config)