diff --git a/.coveragerc b/.coveragerc index 0049349cfff..25aa405035b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -209,7 +209,6 @@ omit = homeassistant/components/lutron_caseta.py homeassistant/components/*/lutron_caseta.py - homeassistant/components/mailgun.py homeassistant/components/*/mailgun.py homeassistant/components/matrix.py diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 76f01ad0aca..85ee6b9fa1c 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -4,18 +4,15 @@ Support to trigger Maker IFTTT recipes. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ifttt/ """ -from ipaddress import ip_address import json import logging -from urllib.parse import urlparse import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.util.network import is_local +from homeassistant.helpers import config_entry_flow REQUIREMENTS = ['pyfttt==0.3'] DEPENDENCIES = ['webhook'] @@ -100,43 +97,11 @@ async def async_unload_entry(hass, entry): hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) return True - -@config_entries.HANDLERS.register(DOMAIN) -class ConfigFlow(config_entries.ConfigFlow): - """Handle an IFTTT config flow.""" - - async def async_step_user(self, user_input=None): - """Handle a user initiated set up flow.""" - if self._async_current_entries(): - return self.async_abort(reason='one_instance_allowed') - - try: - url_parts = urlparse(self.hass.config.api.base_url) - - if is_local(ip_address(url_parts.hostname)): - return self.async_abort(reason='not_internet_accessible') - except ValueError: - # If it's not an IP address, it's very likely publicly accessible - pass - - if user_input is None: - return self.async_show_form( - step_id='user', - ) - - webhook_id = self.hass.components.webhook.async_generate_id() - webhook_url = \ - self.hass.components.webhook.async_generate_url(webhook_id) - - return self.async_create_entry( - title='IFTTT Webhook', - data={ - CONF_WEBHOOK_ID: webhook_id - }, - description_placeholders={ - 'applet_url': 'https://ifttt.com/maker_webhooks', - 'webhook_url': webhook_url, - 'docs_url': - 'https://www.home-assistant.io/components/ifttt/' - } - ) +config_entry_flow.register_webhook_flow( + DOMAIN, + 'IFTTT Webhook', + { + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'docs_url': 'https://www.home-assistant.io/components/ifttt/' + } +) diff --git a/homeassistant/components/mailgun.py b/homeassistant/components/mailgun.py deleted file mode 100644 index 7cb7ef7151d..00000000000 --- a/homeassistant/components/mailgun.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Support for Mailgun. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mailgun/ -""" -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_API_KEY, CONF_DOMAIN -from homeassistant.core import callback -from homeassistant.components.http import HomeAssistantView - - -DOMAIN = 'mailgun' -API_PATH = '/api/{}'.format(DOMAIN) -DATA_MAILGUN = DOMAIN -DEPENDENCIES = ['http'] -MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) -CONF_SANDBOX = 'sandbox' -DEFAULT_SANDBOX = False - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DOMAIN): cv.string, - vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Mailgun component.""" - hass.data[DATA_MAILGUN] = config[DOMAIN] - hass.http.register_view(MailgunReceiveMessageView()) - return True - - -class MailgunReceiveMessageView(HomeAssistantView): - """Handle data from Mailgun inbound messages.""" - - url = API_PATH - name = 'api:{}'.format(DOMAIN) - - @callback - def post(self, request): # pylint: disable=no-self-use - """Handle Mailgun message POST.""" - hass = request.app['hass'] - data = yield from request.post() - hass.bus.async_fire(MESSAGE_RECEIVED, dict(data)) diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json new file mode 100644 index 00000000000..0e993bef5d4 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Mailgun", + "step": { + "user": { + "title": "Set up the Mailgun Webhook", + "description": "Are you sure you want to set up Mailgun?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py new file mode 100644 index 00000000000..25f697084d3 --- /dev/null +++ b/homeassistant/components/mailgun/__init__.py @@ -0,0 +1,67 @@ +""" +Support for Mailgun. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mailgun/ +""" + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID +from homeassistant.helpers import config_entry_flow + +DOMAIN = 'mailgun' +API_PATH = '/api/{}'.format(DOMAIN) +DEPENDENCIES = ['webhook'] +MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) +CONF_SANDBOX = 'sandbox' +DEFAULT_SANDBOX = False + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Mailgun component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = config[DOMAIN] + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Mailgun inbound messages.""" + data = dict(await request.post()) + data['webhook_id'] = webhook_id + hass.bus.async_fire(MESSAGE_RECEIVED, data) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Mailgun Webhook', + { + 'mailgun_url': + 'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks', + 'docs_url': 'https://www.home-assistant.io/components/mailgun/' + } +) diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json new file mode 100644 index 00000000000..0e993bef5d4 --- /dev/null +++ b/homeassistant/components/mailgun/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Mailgun", + "step": { + "user": { + "title": "Set up the Mailgun Webhook", + "description": "Are you sure you want to set up Mailgun?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/notify/mailgun.py b/homeassistant/components/notify/mailgun.py index 1aa403f0ba8..56b0ab7e333 100644 --- a/homeassistant/components/notify/mailgun.py +++ b/homeassistant/components/notify/mailgun.py @@ -8,7 +8,8 @@ import logging import voluptuous as vol -from homeassistant.components.mailgun import CONF_SANDBOX, DATA_MAILGUN +from homeassistant.components.mailgun import ( + CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA) @@ -35,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the Mailgun notification service.""" - data = hass.data[DATA_MAILGUN] + data = hass.data[MAILGUN_DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), data.get(CONF_API_KEY), config.get(CONF_SENDER), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c1c0fbbf775..e00215b8126 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -143,6 +143,7 @@ FLOWS = [ 'ifttt', 'ios', 'lifx', + 'mailgun', 'mqtt', 'nest', 'openuv', diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 569a101b3dd..31d9907d315 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,7 +1,10 @@ """Helpers for data entry flows for config entries.""" from functools import partial +from ipaddress import ip_address +from urllib.parse import urlparse from homeassistant import config_entries +from homeassistant.util.network import is_local def register_discovery_flow(domain, title, discovery_function, @@ -12,6 +15,14 @@ def register_discovery_flow(domain, title, discovery_function, connection_class)) +def register_webhook_flow(domain, title, description_placeholder, + allow_multiple=False): + """Register flow for webhook integrations.""" + config_entries.HANDLERS.register(domain)( + partial(WebhookFlowHandler, domain, title, description_placeholder, + allow_multiple)) + + class DiscoveryFlowHandler(config_entries.ConfigFlow): """Handle a discovery config flow.""" @@ -84,3 +95,50 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): title=self._title, data={}, ) + + +class WebhookFlowHandler(config_entries.ConfigFlow): + """Handle a webhook config flow.""" + + VERSION = 1 + + def __init__(self, domain, title, description_placeholder, + allow_multiple): + """Initialize the discovery config flow.""" + self._domain = domain + self._title = title + self._description_placeholder = description_placeholder + self._allow_multiple = allow_multiple + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create a webhook.""" + if not self._allow_multiple and self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + try: + url_parts = urlparse(self.hass.config.api.base_url) + + if is_local(ip_address(url_parts.hostname)): + return self.async_abort(reason='not_internet_accessible') + except ValueError: + # If it's not an IP address, it's very likely publicly accessible + pass + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + self._description_placeholder['webhook_url'] = webhook_url + + return self.async_create_entry( + title=self._title, + data={ + 'webhook_id': webhook_id + }, + description_placeholders=self._description_placeholder + ) diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 61d6654ba55..21417c99c5b 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -1,5 +1,5 @@ """Test the init file of IFTTT.""" -from unittest.mock import Mock, patch +from unittest.mock import patch from homeassistant import data_entry_flow from homeassistant.core import callback @@ -36,13 +36,3 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): assert len(ifttt_events) == 1 assert ifttt_events[0].data['webhook_id'] == webhook_id assert ifttt_events[0].data['hello'] == 'ifttt' - - -async def test_config_flow_aborts_external_url(hass, aiohttp_client): - """Test setting up IFTTT and sending webhook.""" - hass.config.api = Mock(base_url='http://192.168.1.10') - result = await hass.config_entries.flow.async_init('ifttt', context={ - 'source': 'user' - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'not_internet_accessible' diff --git a/tests/components/mailgun/__init__.py b/tests/components/mailgun/__init__.py new file mode 100644 index 00000000000..3999bce717c --- /dev/null +++ b/tests/components/mailgun/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mailgun component.""" diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py new file mode 100644 index 00000000000..312e3e22bfd --- /dev/null +++ b/tests/components/mailgun/test_init.py @@ -0,0 +1,39 @@ +"""Test the init file of Mailgun.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components import mailgun + +from homeassistant.core import callback + + +async def test_config_flow_registers_webhook(hass, aiohttp_client): + """Test setting up Mailgun and sending webhook.""" + with patch('homeassistant.util.get_local_ip', return_value='example.com'): + result = await hass.config_entries.flow.async_init('mailgun', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + mailgun_events = [] + + @callback + def handle_event(event): + """Handle Mailgun event.""" + mailgun_events.append(event) + + hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), data={ + 'hello': 'mailgun' + }) + + assert len(mailgun_events) == 1 + assert mailgun_events[0].data['webhook_id'] == webhook_id + assert mailgun_events[0].data['hello'] == 'mailgun' diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 9d858e31a06..8e38f76f1c0 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,5 +1,5 @@ """Tests for the Config Entry Flow helper.""" -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest @@ -9,7 +9,7 @@ from tests.common import MockConfigEntry, MockModule @pytest.fixture -def flow_conf(hass): +def discovery_flow_conf(hass): """Register a handler.""" handler_conf = { 'discovered': False, @@ -26,7 +26,18 @@ def flow_conf(hass): yield handler_conf -async def test_single_entry_allowed(hass, flow_conf): +@pytest.fixture +def webhook_flow_conf(hass): + """Register a handler.""" + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_webhook_flow( + 'test_single', 'Test Single', {}, False) + config_entry_flow.register_webhook_flow( + 'test_multiple', 'Test Multiple', {}, True) + yield {} + + +async def test_single_entry_allowed(hass, discovery_flow_conf): """Test only a single entry is allowed.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -38,7 +49,7 @@ async def test_single_entry_allowed(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_user_no_devices_found(hass, flow_conf): +async def test_user_no_devices_found(hass, discovery_flow_conf): """Test if no devices found.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -51,18 +62,18 @@ async def test_user_no_devices_found(hass, flow_conf): assert result['reason'] == 'no_devices_found' -async def test_user_has_confirmation(hass, flow_conf): +async def test_user_has_confirmation(hass, discovery_flow_conf): """Test user requires no confirmation to setup.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM -async def test_discovery_single_instance(hass, flow_conf): +async def test_discovery_single_instance(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -74,7 +85,7 @@ async def test_discovery_single_instance(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_discovery_confirmation(hass, flow_conf): +async def test_discovery_confirmation(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -88,7 +99,7 @@ async def test_discovery_confirmation(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_multiple_discoveries(hass, flow_conf): +async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" loader.set_component(hass, 'test', MockModule('test')) @@ -102,7 +113,7 @@ async def test_multiple_discoveries(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT -async def test_only_one_in_progress(hass, flow_conf): +async def test_only_one_in_progress(hass, discovery_flow_conf): """Test a user initialized one will finish and cancel discovered one.""" loader.set_component(hass, 'test', MockModule('test')) @@ -127,22 +138,71 @@ async def test_only_one_in_progress(hass, flow_conf): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass, flow_conf): +async def test_import_no_confirmation(hass, discovery_flow_conf): """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_single_instance(hass, flow_conf): +async def test_import_single_instance(hass, discovery_flow_conf): """Test import doesn't create second instance.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True MockConfigEntry(domain='test').add_to_hass(hass) result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_webhook_single_entry_allowed(hass, webhook_flow_conf): + """Test only a single entry is allowed.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + MockConfigEntry(domain='test_single').add_to_hass(hass) + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'one_instance_allowed' + + +async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf): + """Test multiple entries are allowed when specified.""" + flow = config_entries.HANDLERS['test_multiple']() + flow.hass = hass + + MockConfigEntry(domain='test_multiple').add_to_hass(hass) + hass.config.api = Mock(base_url='http://example.com') + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_webhook_config_flow_aborts_external_url(hass, + webhook_flow_conf): + """Test configuring a webhook without an external url.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://192.168.1.10') + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'not_internet_accessible' + + +async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): + """Test setting up an entry creates a webhook.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://example.com') + result = await flow.async_step_user(user_input={}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['webhook_id'] is not None