mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Config entry options (#18929)
Add support for options flow for config entries
This commit is contained in:
parent
caa3b123ae
commit
d9712027e8
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user