diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 68890a79ca6..65f65cbcec5 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -17,6 +17,10 @@ async def async_setup(hass): hass.http.register_view( ConfigManagerFlowResourceView(hass.config_entries.flow)) hass.http.register_view(ConfigManagerAvailableFlowView) + hass.http.register_view( + OptionManagerFlowIndexView(hass.config_entries.options.flow)) + hass.http.register_view( + OptionManagerFlowResourceView(hass.config_entries.options.flow)) return True @@ -45,8 +49,9 @@ class ConfigManagerEntryIndexView(HomeAssistantView): name = 'api:config:config_entries:entry' async def get(self, request): - """List flows in progress.""" + """List available config entries.""" hass = request.app['hass'] + return self.json([{ 'entry_id': entry.entry_id, 'domain': entry.domain, @@ -54,6 +59,9 @@ class ConfigManagerEntryIndexView(HomeAssistantView): 'source': entry.source, 'state': entry.state, 'connection_class': entry.connection_class, + 'supports_options': hasattr( + config_entries.HANDLERS[entry.domain], + 'async_get_options_flow'), } for entry in hass.config_entries.async_entries()]) @@ -145,3 +153,48 @@ class ConfigManagerAvailableFlowView(HomeAssistantView): async def get(self, request): """List available flow handlers.""" return self.json(config_entries.FLOWS) + + +class OptionManagerFlowIndexView(FlowManagerIndexView): + """View to create option flows.""" + + url = '/api/config/config_entries/entry/option/flow' + name = 'api:config:config_entries:entry:resource:option:flow' + + # pylint: disable=arguments-differ + async def post(self, request): + """Handle a POST request. + + handler in request is entry_id. + """ + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='edit') + + # pylint: disable=no-value-for-parameter + return await super().post(request) + + +class OptionManagerFlowResourceView(ConfigManagerFlowResourceView): + """View to interact with the option flow manager.""" + + url = '/api/config/config_entries/options/flow/{flow_id}' + name = 'api:config:config_entries:options:flow:resource' + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='edit') + + return await super().get(request, flow_id) + + # pylint: disable=arguments-differ + async def post(self, request, flow_id): + """Handle a POST request.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='edit') + + # pylint: disable=no-value-for-parameter + return await super().post(request, flow_id) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7ff93051c9d..34b30cf422a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -122,7 +122,8 @@ the flow from the config panel. import logging import functools import uuid -from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import +from typing import Callable, Dict, List, Optional, Set # noqa pylint: disable=unused-import +import weakref from homeassistant import data_entry_flow from homeassistant.core import callback, HomeAssistant @@ -130,7 +131,6 @@ from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady from homeassistant.setup import async_setup_component, async_process_deps_reqs from homeassistant.util.decorator import Registry - _LOGGER = logging.getLogger(__name__) _UNDEF = object() @@ -223,12 +223,13 @@ CONN_CLASS_UNKNOWN = 'unknown' class ConfigEntry: """Hold a configuration entry.""" - __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source', - 'connection_class', 'state', '_setup_lock', - '_async_cancel_retry_setup') + __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'options', + 'source', 'connection_class', 'state', '_setup_lock', + 'update_listeners', '_async_cancel_retry_setup') def __init__(self, version: str, domain: str, title: str, data: dict, source: str, connection_class: str, + options: Optional[dict] = None, entry_id: Optional[str] = None, state: str = ENTRY_STATE_NOT_LOADED) -> None: """Initialize a config entry.""" @@ -247,6 +248,9 @@ class ConfigEntry: # Config data self.data = data + # Entry options + self.options = options or {} + # Source of the configuration (user, discovery, cloud) self.source = source @@ -256,6 +260,9 @@ class ConfigEntry: # State of the entry (LOADED, NOT_LOADED) self.state = state + # Listeners to call on update + self.update_listeners = [] # type: list + # Function to cancel a scheduled retry self._async_cancel_retry_setup = None @@ -386,6 +393,18 @@ class ConfigEntry: self.title, component.DOMAIN) return False + def add_update_listener(self, listener: Callable) -> Callable: + """Listen for when entry is updated. + + Listener: Callback function(hass, entry) + + Returns function to unlisten. + """ + weak_listener = weakref.ref(listener) + self.update_listeners.append(weak_listener) + + return lambda: self.update_listeners.remove(weak_listener) + def as_dict(self): """Return dictionary version of this entry.""" return { @@ -394,6 +413,7 @@ class ConfigEntry: 'domain': self.domain, 'title': self.title, 'data': self.data, + 'options': self.options, 'source': self.source, 'connection_class': self.connection_class, } @@ -418,6 +438,7 @@ class ConfigEntries: self.hass = hass self.flow = data_entry_flow.FlowManager( hass, self._async_create_flow, self._async_finish_flow) + self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries = [] # type: List[ConfigEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @@ -435,6 +456,14 @@ class ConfigEntries: return result + @callback + def async_get_entry(self, entry_id: str) -> Optional[ConfigEntry]: + """Return entry with matching entry_id.""" + for entry in self._entries: + if entry_id == entry.entry_id: + return entry + return None + @callback def async_entries(self, domain: Optional[str] = None) -> List[ConfigEntry]: """Return all entries or entries for a specific domain.""" @@ -492,14 +521,25 @@ class ConfigEntries: title=entry['title'], # New in 0.79 connection_class=entry.get('connection_class', - CONN_CLASS_UNKNOWN)) + CONN_CLASS_UNKNOWN), + # New in 0.89 + options=entry.get('options')) for entry in config['entries']] @callback - def async_update_entry(self, entry, *, data=_UNDEF): + def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF): """Update a config entry.""" if data is not _UNDEF: entry.data = data + + if options is not _UNDEF: + entry.options = options + + if data is not _UNDEF or options is not _UNDEF: + for listener_ref in entry.update_listeners: + listener = listener_ref() + self.hass.async_create_task(listener(self.hass, entry)) + self._async_schedule_save() async def async_forward_entry_setup(self, entry, component): @@ -549,6 +589,7 @@ class ConfigEntries: domain=result['handler'], title=result['title'], data=result['data'], + options={}, source=flow.context['source'], connection_class=flow.CONNECTION_CLASS, ) @@ -598,7 +639,7 @@ class ConfigEntries: flow.init_step = source return flow - def _async_schedule_save(self): + def _async_schedule_save(self) -> None: """Save the entity registry to a file.""" self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @@ -631,3 +672,39 @@ class ConfigFlow(data_entry_flow.FlowHandler): return [flw for flw in self.hass.config_entries.flow.async_progress() if flw['handler'] == self.handler and flw['flow_id'] != self.flow_id] + + +class OptionsFlowManager: + """Flow to set options for a configuration entry.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the options manager.""" + self.hass = hass + self.flow = data_entry_flow.FlowManager( + hass, self._async_create_flow, self._async_finish_flow) + + async def _async_create_flow(self, entry_id, *, context, data): + """Create an options flow for a config entry. + + Entry_id and flow.handler is the same thing to map entry with flow. + """ + entry = self.hass.config_entries.async_get_entry(entry_id) + if entry is None: + return + flow = HANDLERS[entry.domain].async_get_options_flow( + entry.data, entry.options) + return flow + + async def _async_finish_flow(self, flow, result): + """Finish an options flow and update options for configuration entry. + + Flow.handler and entry_id is the same thing to map flow with entry. + """ + entry = self.hass.config_entries.async_get_entry(flow.handler) + if entry is None: + return + self.hass.config_entries.async_update_entry( + entry, options=result['data']) + + result['result'] = True + return result diff --git a/tests/common.py b/tests/common.py index 0c1d6854886..a55546da73b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -609,13 +609,14 @@ class MockConfigEntry(config_entries.ConfigEntry): def __init__(self, *, domain='test', data=None, version=1, entry_id=None, source=config_entries.SOURCE_USER, title='Mock Title', - state=None, + state=None, options={}, connection_class=config_entries.CONN_CLASS_UNKNOWN): """Initialize a mock config entry.""" kwargs = { 'entry_id': entry_id or 'mock-id', 'domain': domain, 'data': data or {}, + 'options': options, 'version': version, 'title': title, 'connection_class': connection_class, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index be73906c1bf..87ed83d9a7e 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -7,8 +7,9 @@ from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant import config_entries as core_ce +from homeassistant import config_entries as core_ce, data_entry_flow from homeassistant.config_entries import HANDLERS +from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries from homeassistant.loader import set_component @@ -30,25 +31,37 @@ def client(hass, hass_client): yield hass.loop.run_until_complete(hass_client()) -@asyncio.coroutine -def test_get_entries(hass, client): +async def test_get_entries(hass, client): """Test get entries.""" MockConfigEntry( - domain='comp', - title='Test 1', - source='bla', - connection_class=core_ce.CONN_CLASS_LOCAL_POLL, + domain='comp', + title='Test 1', + source='bla', + connection_class=core_ce.CONN_CLASS_LOCAL_POLL, ).add_to_hass(hass) MockConfigEntry( - domain='comp2', - title='Test 2', - source='bla2', - state=core_ce.ENTRY_STATE_LOADED, - connection_class=core_ce.CONN_CLASS_ASSUMED, + domain='comp2', + title='Test 2', + source='bla2', + state=core_ce.ENTRY_STATE_LOADED, + connection_class=core_ce.CONN_CLASS_ASSUMED, ).add_to_hass(hass) - resp = yield from client.get('/api/config/config_entries/entry') + + class CompConfigFlow: + @staticmethod + @callback + def async_get_options_flow(config, options): + pass + HANDLERS['comp'] = CompConfigFlow() + + class Comp2ConfigFlow: + def __init__(self): + pass + HANDLERS['comp2'] = Comp2ConfigFlow() + + resp = await client.get('/api/config/config_entries/entry') assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() for entry in data: entry.pop('entry_id') assert data == [ @@ -58,6 +71,7 @@ def test_get_entries(hass, client): 'source': 'bla', 'state': 'not_loaded', 'connection_class': 'local_poll', + 'supports_options': True, }, { 'domain': 'comp2', @@ -65,6 +79,7 @@ def test_get_entries(hass, client): 'source': 'bla2', 'state': 'loaded', 'connection_class': 'assumed', + 'supports_options': False, }, ] @@ -467,3 +482,136 @@ async def test_get_progress_flow_unauth(hass, client, hass_admin_user): '/api/config/config_entries/flow/{}'.format(data['flow_id'])) assert resp2.status == 401 + + +async def test_options_flow(hass, client): + """Test we can change options.""" + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_options_flow(config, options): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + def __init__(self, config, options): + self.config = config + self.options = options + + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('enabled')] = bool + return self.async_show_form( + step_id='user', + data_schema=schema, + description_placeholders={ + 'enabled': 'Set to true to be true', + } + ) + return OptionsFlowHandler(config, options) + + MockConfigEntry( + domain='test', + entry_id='test1', + source='bla', + connection_class=core_ce.CONN_CLASS_LOCAL_POLL, + ).add_to_hass(hass) + entry = hass.config_entries._entries[0] + + with patch.dict(HANDLERS, {'test': TestFlow}): + url = '/api/config/config_entries/entry/option/flow' + resp = await client.post(url, json={'handler': entry.entry_id}) + + assert resp.status == 200 + data = await resp.json() + + data.pop('flow_id') + assert data == { + 'type': 'form', + 'handler': 'test1', + 'step_id': 'user', + 'data_schema': [ + { + 'name': 'enabled', + 'required': True, + 'type': 'boolean' + }, + ], + 'description_placeholders': { + 'enabled': 'Set to true to be true', + }, + 'errors': None + } + + +async def test_two_step_options_flow(hass, client): + """Test we can finish a two step options flow.""" + set_component( + hass, 'test', + MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_options_flow(config, options): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + def __init__(self, config, options): + self.config = config + self.options = options + + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id='finish', + data_schema=vol.Schema({ + 'enabled': bool + }) + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title='Enable disable', + data=user_input + ) + return OptionsFlowHandler(config, options) + + MockConfigEntry( + domain='test', + entry_id='test1', + source='bla', + connection_class=core_ce.CONN_CLASS_LOCAL_POLL, + ).add_to_hass(hass) + entry = hass.config_entries._entries[0] + + with patch.dict(HANDLERS, {'test': TestFlow}): + url = '/api/config/config_entries/entry/option/flow' + resp = await client.post(url, json={'handler': entry.entry_id}) + + assert resp.status == 200 + data = await resp.json() + flow_id = data.pop('flow_id') + assert data == { + 'type': 'form', + 'handler': 'test1', + 'step_id': 'finish', + 'data_schema': [ + { + 'name': 'enabled', + 'type': 'boolean' + } + ], + 'description_placeholders': None, + 'errors': None + } + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = await client.post( + '/api/config/config_entries/options/flow/{}'.format(flow_id), + json={'enabled': True}) + assert resp.status == 200 + data = await resp.json() + data.pop('flow_id') + assert data == { + 'handler': 'test1', + 'type': 'create_entry', + 'title': 'Enable disable', + 'version': 1, + 'description': None, + 'description_placeholders': None, + } diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e724680a05b..8991035cc22 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, loader, data_entry_flow +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -544,6 +545,31 @@ async def test_updating_entry_data(manager): } +async def test_update_entry_options_and_trigger_listener(hass, manager): + """Test that we can update entry options and trigger listener.""" + entry = MockConfigEntry( + domain='test', + options={'first': True}, + ) + entry.add_to_manager(manager) + + async def update_listener(hass, entry): + """Test function.""" + assert entry.options == { + 'second': True + } + + entry.add_update_listener(update_listener) + + manager.async_update_entry(entry, options={ + 'second': True + }) + + assert entry.options == { + 'second': True + } + + async def test_setup_raise_not_ready(hass, caplog): """Test a setup raising not ready.""" entry = MockConfigEntry(domain='test') @@ -588,3 +614,39 @@ async def test_setup_retrying_during_unload(hass): assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED assert len(mock_call.return_value.mock_calls) == 1 + + +async def test_entry_options(hass, manager): + """Test that we can set options on an entry.""" + entry = MockConfigEntry( + domain='test', + data={'first': True}, + options=None + ) + entry.add_to_manager(manager) + + class TestFlow: + @staticmethod + @callback + def async_get_options_flow(config, options): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + def __init__(self, config, options): + pass + return OptionsFlowHandler(config, options) + + config_entries.HANDLERS['test'] = TestFlow() + flow = await manager.options._async_create_flow( + entry.entry_id, context={'source': 'test'}, data=None) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + await manager.options._async_finish_flow( + flow, {'data': {'second': True}}) + + assert entry.data == { + 'first': True + } + + assert entry.options == { + 'second': True + }