From 5888e323603b48b932616d1e917872f8a0573c42 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 May 2019 05:33:50 -0700 Subject: [PATCH] Add support for an external step in config flow (#23782) * Add support for an external step in config flow * Types * Lint --- homeassistant/data_entry_flow.py | 69 ++++++++++++++++++++---- homeassistant/helpers/data_entry_flow.py | 2 +- tests/common.py | 13 +++++ tests/test_data_entry_flow.py | 56 +++++++++++++++++++ 4 files changed, 129 insertions(+), 11 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index acd0befda4e..aa1d21a66d3 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -11,6 +11,11 @@ _LOGGER = logging.getLogger(__name__) RESULT_TYPE_FORM = 'form' RESULT_TYPE_CREATE_ENTRY = 'create_entry' RESULT_TYPE_ABORT = 'abort' +RESULT_TYPE_EXTERNAL_STEP = 'external' +RESULT_TYPE_EXTERNAL_STEP_DONE = 'external_done' + +# Event that is fired when a flow is progressed via external source. +EVENT_DATA_ENTRY_FLOW_PROGRESSED = 'data_entry_flow_progressed' class FlowError(HomeAssistantError): @@ -71,13 +76,31 @@ class FlowManager: if flow is None: raise UnknownFlow - step_id, data_schema = flow.cur_step + cur_step = flow.cur_step - if data_schema is not None and user_input is not None: - user_input = data_schema(user_input) + if cur_step.get('data_schema') is not None and user_input is not None: + user_input = cur_step['data_schema'](user_input) - return await self._async_handle_step( - flow, step_id, user_input) + result = await self._async_handle_step( + flow, cur_step['step_id'], user_input) + + if cur_step['type'] == RESULT_TYPE_EXTERNAL_STEP: + if result['type'] not in (RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_EXTERNAL_STEP_DONE): + raise ValueError("External step can only transition to " + "external step or external step done.") + + # If the result has changed from last result, fire event to update + # the frontend. + if cur_step['step_id'] != result.get('step_id'): + # Tell frontend to reload the flow state. + self.hass.bus.async_fire(EVENT_DATA_ENTRY_FLOW_PROGRESSED, { + 'handler': flow.handler, + 'flow_id': flow_id, + 'refresh': True + }) + + return result @callback def async_abort(self, flow_id: str) -> None: @@ -97,13 +120,15 @@ class FlowManager: result = await getattr(flow, method)(user_input) # type: Dict - if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT): + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT, + RESULT_TYPE_EXTERNAL_STEP_DONE): raise ValueError( 'Handler returned incorrect type: {}'.format(result['type'])) - if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) + if result['type'] in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP, + RESULT_TYPE_EXTERNAL_STEP_DONE): + flow.cur_step = result return result # We pass a copy of the result because we're mutating our version @@ -111,7 +136,7 @@ class FlowManager: # _async_finish_flow may change result type, check it again if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) + flow.cur_step = result return result # Abort and Success results both finish the flow @@ -180,3 +205,27 @@ class FlowHandler: 'reason': reason, 'description_placeholders': description_placeholders, } + + @callback + def async_external_step(self, *, step_id: str, url: str, + description_placeholders: Optional[Dict] = None) \ + -> Dict: + """Return the definition of an external step for the user to take.""" + return { + 'type': RESULT_TYPE_EXTERNAL_STEP, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': step_id, + 'url': url, + 'description_placeholders': description_placeholders, + } + + @callback + def async_external_step_done(self, *, next_step_id: str) -> Dict: + """Return the definition of an external step for the user to take.""" + return { + 'type': RESULT_TYPE_EXTERNAL_STEP_DONE, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': next_step_id, + } diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 378febf8f6d..d3ac4763269 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -58,7 +58,7 @@ class FlowManagerIndexView(_BaseFlowManagerView): except data_entry_flow.UnknownHandler: return self.json_message('Invalid handler specified', 404) except data_entry_flow.UnknownStep: - return self.json_message('Handler does not support init', 400) + return self.json_message('Handler does not support user', 400) result = self._prepare_result_json(result) diff --git a/tests/common.py b/tests/common.py index 8b28d9db047..f5a2b1327fe 100644 --- a/tests/common.py +++ b/tests/common.py @@ -951,3 +951,16 @@ def mock_entity_platform(hass, platform_path, module): _LOGGER.info("Adding mock integration platform: %s", platform_path) module_cache["{}.{}".format(platform_name, domain)] = module + + +def async_capture_events(hass, event_name): + """Create a helper that captures events.""" + events = [] + + @ha.callback + def capture_events(event): + events.append(event) + + hass.bus.async_listen(event_name, capture_events) + + return events diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index aa8240ff567..f6e33d264b6 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -5,6 +5,8 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.util.decorator import Registry +from tests.common import async_capture_events + @pytest.fixture def manager(): @@ -245,3 +247,57 @@ async def test_finish_callback_change_result_type(hass): result = await manager.async_configure(result['flow_id'], {'count': 2}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['result'] == 2 + + +async def test_external_step(hass, manager): + """Test external step logic.""" + manager.hass = hass + + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + if not user_input: + return self.async_external_step( + step_id='init', + url='https://example.com', + ) + + self.data = user_input + return self.async_external_step_done(next_step_id='finish') + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title=self.data['title'], + data=self.data + ) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + result = await manager.async_init('test') + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert len(manager.async_progress()) == 1 + + # Mimic external step + # Called by integrations: `hass.config_entries.flow.async_configure(…)` + result = await manager.async_configure(result['flow_id'], { + 'title': 'Hello' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data == { + 'handler': 'test', + 'flow_id': result['flow_id'], + 'refresh': True + } + + # Frontend refreshses the flow + result = await manager.async_configure(result['flow_id']) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == "Hello"